54 Commits
v1.11 ... main

Author SHA1 Message Date
4435bc4942 arreglos en makefile de macos 2026-05-03 18:07:13 +02:00
4a4485c6f8 bugfixes 2026-04-18 18:16:41 +02:00
d09bb1cf6b actualitzat changelog 2026-04-18 17:57:05 +02:00
b1f9e57f36 fix: color de fonde dels sliders de 050505 a 000000 2026-04-18 15:20:25 +02:00
f7875baa2d refactor: fase 6 — Rule of 5 a Mapa i ModuleGame (no-copiables, no-movibles)
- Mapa té un JD8_Surface fondo propi que s'allibera al destructor: una
  còpia accidental provocaria double-free. Ara els 4 copy/move ops estan
  = delete.
- ModuleGame ja era no-copiable implícitament per tindre unique_ptr
  members, però els = delete expliciten la intenció i protegeixen
  davant refactors futurs que afegeixquen tipus copiables.

Fi de la modernització RAII per fases (1–6).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 14:03:51 +02:00
c6e37af7d1 refactor: fase 5 — singletons a std::unique_ptr (elimina new/delete manual)
5 singletons afectats: Audio, Screen, Director, Resource::Cache, Resource::List.

- static T* instance → static std::unique_ptr<T> instance
- init(): new T() adoptat immediatament per unique_ptr (ownership RAII)
- destroy(): instance.reset() (sense delete manual)
- get(): retorna instance.get()
- Destructors moguts a public perquè std::default_delete hi pugui accedir
  (ctors privats + copy/move deleted → encapsulació efectiva mantinguda)

Ordre de destrucció preservat: SDL_AppQuit segueix cridant destroy() en
l'ordre invers a init() — la RAII automàtica no s'activa fins al final
del programa (LIFO de variables static).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 14:02:01 +02:00
5e57034a38 refactor: fase 4 — llista enllaçada de Momia a std::vector<unique_ptr>
Eliminada completament la recursivitat per next-pointer:
- Momia::next, clear(), insertar() desapareixen
- update()/draw() no recursen: operen només sobre la instància pròpia
- ModuleGame::momies: Momia* (head de llista) → std::vector<std::unique_ptr<Momia>>
  - Destructor simplificat (vector s'autodestrueix)
  - Draw: range-for sobre el vector
  - Update: std::erase_if + decrement sincronitzat de info::ctx.momies
  - Cheat "alone": momies.clear()
  - iniciarMomies i nova_momia: emplace_back(std::make_unique<Momia>(...))

Zero new/delete manuals al cicle de vida de les momies.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 13:56:05 +02:00
2a8fbbb095 refactor: fase 3 — Text::bitmap_ a std::vector<Uint8>
- bitmap_: Uint8* (owning, free'd al destructor) → std::vector<Uint8>
- loadBitmap copia des del buffer de LoadGif i fa free(pixels) de
  l'intermedi (gif.h usa malloc internament)
- ~Text() eliminat: regla 0 aplicada (vector es destrueix sol)
- Les 4 comprovacions !bitmap_ → bitmap_.empty()

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 13:44:07 +02:00
53e93ef697 refactor: fase 2 — elimina malloc/free a jdraw8 i paletes d'escenes
- JD8_Init/Quit: new[]/delete[] per a screen, main_palette, pixel_data
- JD8_NewSurface/FreeSurface: new Uint8[64000]{}/delete[]
- JD8_LoadPalette: uniforme — sempre retorna `new Color[256]`, copiant del
  LoadPalette extern al path no-cached (l'intermedi raw es frees amb free()
  perquè gif.h el malloca)
- JD8_SetScreenPalette: delete[] la paleta reemplaçada
- slides/secreta/menu/banner/mort scenes: std::free/std::malloc → delete[]/new Color[256]

Ownership uniforme: tot el cicle de vida de surface/palette usa new[]/delete[].

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 13:42:31 +02:00
e7aa2463b4 refactor: fase 1 — cleanup mecànic de baix risc (NULL→nullptr, typedef→using, explicit, enum class local)
- jdraw8.hpp: typedef → using (JD8_Surface, JD8_Palette)
- jdraw8.cpp: NULL → nullptr, C-casts → static_cast/reinterpret_cast, anon enum FadeType → enum class
- momia.cpp: NULL → nullptr
- bola/mapa/marcador/momia/engendro: explicit als constructors

Zero canvis de lògica ni ownership. Primera fase de la modernització RAII.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 13:37:48 +02:00
27f8b0ae36 cppcheck 2026-04-18 13:22:13 +02:00
2e1a82ff40 afegit suppress a cppcheck 2026-04-18 12:55:27 +02:00
94aa69cffe afegit resource::cache
normalitzat Audio
2026-04-18 11:41:34 +02:00
7409c799c3 build: unifica .clang-format/.clang-tidy i exclou external/ i spv/ amb dummies 2026-04-17 16:21:56 +02:00
417699d276 renombrats els fitxers de musica 2026-04-17 13:29:07 +02:00
9d86137203 arreglos en make i cmake per estandaritzar amb la resta de projectes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 12:59:31 +02:00
52369be7ae el logo nou de la intro es tornava a descentrar 2026-04-16 22:15:37 +02:00
1c11a3057b afegits events de canvas d'emscripten 2026-04-16 22:12:30 +02:00
e8b0b12f98 internal resolution 2026-04-16 21:40:14 +02:00
16a3f5b470 treballant en internal resolution 2026-04-16 20:53:13 +02:00
5cda8fc3f9 centrat correctament el logo de jailgames (el nou) 2026-04-16 20:18:28 +02:00
5956d874c3 animacio de tancar el menu 2026-04-16 20:14:35 +02:00
e0f9b60f22 menu de sistema amb versió i opció per a tancar i reiniciar 2026-04-16 20:01:58 +02:00
d3bdd9b783 afegit fix de mandos en emscripten android 2026-04-16 19:35:48 +02:00
a36662ac6e fix: shaders on i off no afectaven a crtpi 2026-04-16 19:26:45 +02:00
52431adb0e afegits tots els valors d'escala que dona sdl3 2026-04-16 19:15:35 +02:00
a3fc1119ae menu ara permet amagar items en funció d'altres items 2026-04-16 19:01:35 +02:00
6394e9afab varies coses i detallets 2026-04-16 18:46:58 +02:00
fe41919e1e clang-format
mogudes coses de config.yaml a debug.yaml
2026-04-16 16:46:18 +02:00
0cd09f6d28 idem 2026-04-16 16:37:38 +02:00
083a57dab5 ordenada la carpeta data 2026-04-16 16:37:30 +02:00
4244bcaea3 acabat amb resource.pack 2026-04-16 16:21:44 +02:00
b2d5f5af61 feat: resource.pack estil coffee_crisis — Fase 1 (pack + helper + eina pack_resources) 2026-04-16 13:58:39 +02:00
7f26b8dbd0 opcions per defecte d'emscripten 2026-04-16 13:40:21 +02:00
550e3e0e12 refactor: JA_Sound_t RAII — buffer amb unique_ptr + SDLFreeDeleter, elimina JA_NewSound 2026-04-16 13:28:31 +02:00
96a3cf9ebc step B.2: elimina fiber — Director posseeix l'escena, JD8_Flip sense yield, fiber.hpp/cpp esborrats 2026-04-16 11:14:48 +02:00
4e18f83ec5 step B.1: fades de ModuleGame tick-based amb scenes::PaletteFade (fases FadingIn/FadingOut sense redibuixar, per no perdre el frame final) 2026-04-16 10:27:04 +02:00
f9346add79 refactor: jail_audio RAII polish — JA_Music_t amb vector<Uint8>/string + elimina overload i camp morts 2026-04-16 10:02:55 +02:00
b3ff620c81 refactor: file_getfilebuffer → file_readfile (std::vector<char>) — elimina 3 leaks (paleta + música gameplay + música cinemàtica) 2026-04-16 09:43:27 +02:00
d343e719ca step 9: intro_sprites_scene com a sub-escena (elimina doIntroSprites + 3 variants aleatòries) 2026-04-16 08:38:47 +02:00
e18b7321eb step 8: intro_scene substituix doIntro() (revelat JAILGAMES lletra a lletra + cicle de paleta) 2026-04-16 08:00:22 +02:00
6125277d70 docs: plan de migració scenes:: al repo (per a continuar des d'altres equips) 2026-04-16 00:18:09 +02:00
6063b1c606 step 7: secreta_scene amb swap de tomba1→tomba2 i red pulse animat 2026-04-16 00:13:02 +02:00
829d7431c1 step 6: credits_scene substituix doCredits() (scroll vertical + parallax condicional) 2026-04-16 00:03:25 +02:00
605c273173 step 5: slides_scene amb wipe suau per easing (substituix doSlides) 2026-04-15 23:50:59 +02:00
ad38fc09cf step 4: intro_new_logo_scene substituix doIntroNewLogo(); doIntroSprites exposat temporalment 2026-04-15 23:28:22 +02:00
8720e775a0 step 3: menu_scene substituix doMenu() + fix JI_Update al fiber loop 2026-04-15 23:19:58 +02:00
2cb38ffb49 step 2: banner_scene substituix doBanner() (piràmides 2-5) + helper playMusic compartit 2026-04-15 23:13:05 +02:00
d86cb21efa step 1: mort_scene substituix doMort() amb la capa scenes:: 2026-04-15 23:05:45 +02:00
4436f7f569 scenes: infraestructura de la capa scenes:: (scene, timeline, sprite mover, frame animator, palette fade, surface handle, registry) 2026-04-15 19:40:39 +02:00
1507a1c740 fase 4+5: fibers cooperatius substitueixen el game thread, sense mutex ni cv 2026-04-15 18:50:43 +02:00
801a8ad1bd fase 3: port de jail_audio header-only amb streaming i sense SDL_AddTimer 2026-04-15 18:23:34 +02:00
80fa7b46e7 fase 2: fades de jd8 a màquina d'estats i helper wait_frame_or_skip a les cinemàtiques 2026-04-15 18:12:03 +02:00
7f85b50c63 fase 1: jail i game a c++ idiomàtic (raii, info::ctx, cheats arreglats) 2026-04-15 18:03:46 +02:00
149 changed files with 9499 additions and 4093 deletions

79
.clang-tidy Normal file
View File

@@ -0,0 +1,79 @@
Checks:
- readability-*
- modernize-*
- performance-*
- bugprone-*
- -readability-identifier-length
- -readability-magic-numbers
- -bugprone-integer-division
- -bugprone-easily-swappable-parameters
- -bugprone-narrowing-conversions
- -modernize-avoid-c-arrays,-warnings-as-errors
WarningsAsErrors: '*'
# Solo headers del propio código fuente (external/ y spv/ tienen su propio .clang-tidy dummy)
HeaderFilterRegex: 'source/.*'
FormatStyle: file
CheckOptions:
# bugprone-empty-catch: aceptar catches vacíos marcados con @INTENTIONAL en un comentario
- { key: bugprone-empty-catch.IgnoreCatchWithKeywords, value: '@INTENTIONAL' }
# Variables locales en snake_case
- { key: readability-identifier-naming.VariableCase, value: lower_case }
# Miembros privados en snake_case con sufijo _
- { key: readability-identifier-naming.PrivateMemberCase, value: lower_case }
- { key: readability-identifier-naming.PrivateMemberSuffix, value: _ }
# Miembros protegidos en snake_case con sufijo _
- { key: readability-identifier-naming.ProtectedMemberCase, value: lower_case }
- { key: readability-identifier-naming.ProtectedMemberSuffix, value: _ }
# Miembros públicos en snake_case (sin sufijo)
- { key: readability-identifier-naming.PublicMemberCase, value: lower_case }
# Namespaces en CamelCase
- { key: readability-identifier-naming.NamespaceCase, value: CamelCase }
# Variables estáticas privadas como miembros privados
- { key: readability-identifier-naming.StaticVariableCase, value: lower_case }
- { key: readability-identifier-naming.StaticVariableSuffix, value: _ }
# Constantes estáticas sin sufijo
- { key: readability-identifier-naming.StaticConstantCase, value: UPPER_CASE }
# Constantes globales en UPPER_CASE
- { key: readability-identifier-naming.GlobalConstantCase, value: UPPER_CASE }
# Variables constexpr globales en UPPER_CASE
- { key: readability-identifier-naming.ConstexprVariableCase, value: UPPER_CASE }
# Constantes locales en UPPER_CASE
- { key: readability-identifier-naming.LocalConstantCase, value: UPPER_CASE }
# Constexpr miembros en UPPER_CASE (sin sufijo)
- { key: readability-identifier-naming.ConstexprMemberCase, value: UPPER_CASE }
# Constexpr miembros privados/protegidos con sufijo _
- { key: readability-identifier-naming.ConstexprMethodCase, value: UPPER_CASE }
# Clases, structs y enums en CamelCase
- { key: readability-identifier-naming.ClassCase, value: CamelCase }
- { key: readability-identifier-naming.StructCase, value: CamelCase }
- { key: readability-identifier-naming.EnumCase, value: CamelCase }
# Valores de enums en UPPER_CASE
- { key: readability-identifier-naming.EnumConstantCase, value: UPPER_CASE }
# Métodos en camelBack (sin sufijos)
- { key: readability-identifier-naming.MethodCase, value: camelBack }
- { key: readability-identifier-naming.PrivateMethodCase, value: camelBack }
- { key: readability-identifier-naming.ProtectedMethodCase, value: camelBack }
- { key: readability-identifier-naming.PublicMethodCase, value: camelBack }
# Funciones en camelBack
- { key: readability-identifier-naming.FunctionCase, value: camelBack }
# Parámetros en lower_case
- { key: readability-identifier-naming.ParameterCase, value: lower_case }

View File

@@ -0,0 +1 @@
{"sessionId":"7b0c9c32-3dd4-48a3-ba06-c2303dc08243","pid":123890,"acquiredAt":1776510185734}

61
.gitignore vendored
View File

@@ -1,8 +1,57 @@
aee # --- Build outputs ---
aee.exe
.DS_Store
trick.ini
.vscode/
data.jrf
build/ build/
dist/ dist/
aee
aee.exe
*.o
*.obj
*.exe
*.app
# --- Generated assets ---
resources.pack
data.jrf
# --- Runtime / debug junk ---
trick.ini
*.log
*.dmp
# --- Editor / IDE ---
.vscode/
.idea/
*.swp
*.swo
*~
.cache/
compile_commands.json
# --- macOS ---
.DS_Store
.AppleDouble
.LSOverride
._*
.Spotlight-V100
.Trashes
.fseventsd
.DocumentRevisions-V100
.TemporaryItems
.VolumeIcon.icns
Icon?
# --- Windows ---
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
Desktop.ini
desktop.ini
$RECYCLE.BIN/
*.lnk
# --- Linux ---
*~
.fuse_hidden*
.directory
.Trash-*
.nfs*

View File

@@ -1,6 +1,88 @@
# Changelog # Changelog
Tots els canvis de la reconstrucció moderna (C++/SDL3) d'**Aventures en Egipte**, des de l'inici del port fins a la v1.1. Tots els canvis de la reconstrucció moderna (C++/SDL3) d'**Aventures en Egipte**.
## [1.2] — 2026-04-18
Versió de modernització profunda: desapareix el model *threads estil emulador* i tot el runtime passa a un sol fil tick-based compatible amb emscripten. Zero regressions de gameplay.
### Afegit
#### Arquitectura: capa `scenes::` tick-based
- Infraestructura `scenes::` ([source/scenes/](source/scenes/)): `Scene`, `SceneRegistry`, `Timeline`, `SpriteMover`, `FrameAnimator`, `PaletteFade`, `SurfaceHandle`, helper `playMusic` (`4436f7f`)
- **MortScene** substitueix `doMort()` (`d86cb21`)
- **BannerScene** substitueix `doBanner()` per piràmides 25 (`2cb38ff`)
- **MenuScene** substitueix `doMenu()` + fix `JI_Update` al loop (`8720e77`)
- **IntroNewLogoScene** substitueix `doIntroNewLogo()` (`ad38fc0`)
- **SlidesScene** amb wipe suau per easing (`605c273`)
- **CreditsScene** amb scroll vertical + parallax condicional (`829d743`)
- **SecretaScene** amb swap `tomba1→tomba2` i red pulse animat (`6063b1c`)
- **IntroScene** amb revelat *JAILGAMES* lletra a lletra + cicle de paleta (`e18b732`)
- **IntroSpritesScene** com a sub-escena amb 3 variants aleatòries (`d343e71`)
- **ModuleGame** migrat a `scenes::Scene` amb fases `FadingIn`/`FadingOut` (`4e18f83`)
- Pla de migració documentat a [docs/scenes-migration-plan.md](docs/scenes-migration-plan.md) (`6125277`)
#### Resource pack
- Sistema d'empaquetat d'assets `resources.pack` (format **AEE1**, XOR-xifrat) estil *coffee_crisis* (`b2d5f5a`, `4244bca`)
- Classe `ResourcePack` + namespace `ResourceHelper` + eina CLI standalone `pack_resources` (target `make pack`)
- Cablejat a tots els callsites de recursos via `ResourceHelper::loadFile`
- Scaffold `.jrf` llegat eliminat completament de `jfile.cpp`
- Releases natius depenen del pack i l'usen obligatòriament (sense fallback); WASM i Debug mantenen fallback
- Normalització de `resource::cache` per a `Audio` (`94aa69c`)
#### Build WebAssembly
- Build WASM via Docker (`emscripten/emsdk:latest`) amb desplegament a maverick (`make wasm`)
- SDL3 compilat des de font via `FetchContent`; shaders omesos; `sdl3gpu_shader.cpp` exclòs
- Events de canvas d'emscripten (`1c11a30`)
- Fix de mandos en emscripten Android (`d3bdd9b`)
- Defaults específics d'emscripten (`7f26b8d`)
- Internal resolution configurable (`e8b0b12`, `16a3f5b`)
#### Menú i UI
- **Menú de sistema** amb versió i opció de tancar/reiniciar (`e0f9b60`)
- Animació de tancar el menú (`5956d87`)
- Items ocultables condicionalment en funció d'altres items (`a3fc111`)
- Tots els valors d'escala que exposa SDL3 (`52431ad`)
- `debug.yaml` separat de `config.yaml` (`fe41919`)
### Canviat
#### Runtime: sense fibers, sense threads, sense mutex
- **Fase 1** — jail i game a C++ idiomàtic: RAII, `info::ctx` com a singleton `inline`, cheats arreglats (`scancode→ASCII`) (`7f85b50`)
- **Fase 2** — fades de `jd8` a màquina d'estats + helper `wait_frame_or_skip` a les cinemàtiques (`80fa7b4`)
- **Fase 3** — `jail_audio` header-only amb streaming real (`stb_vorbis_open_memory` + `JA_PumpMusic`), sense `SDL_AddTimer` (`801a8ad`)
- **Fase 4+5** — fibers cooperatius substitueixen el game thread, sense mutex ni `cv` (`1507a1c`)
- **Step B.1** — fades de `ModuleGame` tick-based amb `scenes::PaletteFade` (`4e18f83`)
- **Step B.2** — **eliminació total del fiber**: `Director` posseeix l'escena (`current_scene_`, `game_state_`), `JD8_Flip` sense yield, `fiber.{hpp,cpp}` esborrats (`96a3cf9`)
- **Step 10** — `ModuleSequence` eliminat; dispatch via `SceneRegistry::tryCreate()` i `game_state_ == 0/1` directe des del `Director`
- Main loop via **SDL3 Callback API** (`SDL_MAIN_USE_CALLBACKS`): `SDL_AppInit`/`Iterate`/`Event`/`Quit`, compatible amb emscripten
#### RAII i neteja de memòria
- **Fase 1** — cleanup mecànic: `NULL→nullptr`, `typedef→using`, `explicit`, `enum class` local (`e7aa246`)
- **Fase 2** — elimina `malloc`/`free` a `jdraw8` i paletes d'escenes (`53e93ef`)
- **Fase 3** — `Text::bitmap_` a `std::vector<Uint8>` (`2a8fbbb`)
- **Fase 4** — llista enllaçada de Momia a `std::vector<std::unique_ptr>` (`5e57034`)
- **Fase 5** — singletons a `std::unique_ptr` (elimina `new`/`delete` manual) (`c6e37af`)
- **Fase 6** — Rule of 5 a `Mapa` i `ModuleGame` (no-copiables, no-movibles) (`f7875ba`)
- `file_getfilebuffer``file_readfile` retornant `std::vector<char>` — elimina 3 leaks silenciosos (paleta + música gameplay + música cinemàtica) (`b3ff620`)
- `JA_Music_t` RAII amb `vector<Uint8>`/`string`, elimina overload i camps morts (`f9346ad`)
- `JA_Sound_t` RAII amb `unique_ptr + SDLFreeDeleter`, elimina `JA_NewSound` (`550e3e0`)
#### Build i tooling
- Unificats `.clang-format` i `.clang-tidy`, amb exclusió de `external/` i `spv/` via dummies (`7409c79`)
- `cppcheck` integrat amb suppress list (`27f8b0a`, `2e1a82f`)
- `make`/`cmake` estandarditzats amb la resta de projectes JailGames (`9d86137`)
- Fitxers de música renombrats a noms temàtics (`417699d`)
- Carpeta `data/` reordenada (`083a57d`)
### Arreglat
- Shaders ON/OFF no afectaven a CRT-Pi (`a36662a`)
- Logo nou de la intro tornava a descentrar-se (`52369be`, `5cda8fc`)
- Color de fons dels sliders de `0x050505` a `0x000000` (`b1f9e57`)
- Diversos detalls menors (`6394e9a`, `0cd09f6`)
---
## [1.1] — 2026-04-05 ## [1.1] — 2026-04-05
@@ -64,4 +146,5 @@ Versió que fa coincidir la numeració amb la del joc original del 2000.
--- ---
[1.1]: https://gitea/aee/compare/9e0ab87...HEAD [1.2]: https://gitea/aee/compare/486f00b...HEAD
[1.1]: https://gitea/aee/compare/9e0ab87...486f00b

197
CLAUDE.md
View File

@@ -24,26 +24,74 @@ The executable is output to the project root. The `data/` folder must be in the
## Architecture ## Architecture
### Golden Rule: Do Not Touch Gameplay ### New Rules (Modernization Phase)
The original game logic (gameplay, entities, map, scoring, collisions, animations) must remain untouched. All modernization work targets the presentation layer and infrastructure only. Any new feature must be implemented as an overlay on top of the existing game, never by modifying original gameplay code. The old "Golden Rule: Do Not Touch Gameplay" has been **revoked**. The original C-style code (jail engine + gameplay modules) is now a **modernization target**, not a sacred zone. The parallel-overlay approach has reached its ceiling: fades and cinematics are still blocking loops, audio relies on an async `SDL_AddTimer`, and the emulator-style game thread blocking at `publishFrame` is incompatible with an emscripten port.
The five current objectives are:
1. **Idiomatic C++**: RAII, `std::vector`/`std::string`/`std::optional`, classes with real constructors/destructors. No more raw `malloc/free` in structs.
2. **Zero blocking events**: no `while (...) { poll; }`, no `SDL_Delay` inside gameplay, no `cv.wait()` in `publishFrame`. Every subsystem must be able to advance in a single tick call.
3. **Time-based**: animations, cinematics and fades measured in milliseconds, not frames. `JG_ShouldUpdate()` as gameplay gate is on its way out.
4. **Overlay integrated**: overlay stops being a post-game layer painted by Director — it becomes part of the same render pass the game tick produces.
5. **SDL3 callbacks**: main loop handed over to `SDL_AppInit` / `SDL_AppIterate` / `SDL_AppEvent` / `SDL_AppQuit`, single-threaded, compatible with emscripten.
**Iron rule: zero gameplay regressions.** Each phase of the modernization must leave the game playing identically — same feel, same timings, same collisions, same scoring. See [docs/scenes-migration-plan.md](docs/scenes-migration-plan.md) for the phased plan.
### Migration Status (2026-04-16)
**Completat.** Totes les fases del pla original (07) i la migració `scenes::` (Steps 010) estan fetes, ModuleGame és una `scenes::Scene` tick-based, el cooperative fiber s'ha eliminat, i el build emscripten/WASM arrenca i es publica a maverick.
**Arquitectura actual**:
- Un sol thread (Director). Main loop via SDL3 Callback API (`SDL_MAIN_USE_CALLBACKS`): `SDL_AppInit/Iterate/Event/Quit` a [main.cpp](source/main.cpp).
- `Director::iterate()` posseeix l'estat d'escena (`current_scene_`, `game_state_`) i fa input → tick de l'escena → `JD8_Flip` (sense yield, només converteix `screen``pixel_data`) → overlay → present. Tot en línia recta, zero fibers, zero mutex.
- Totes les escenes (inclòs `ModuleGame`) implementen `scenes::Scene` amb `onEnter/tick(delta_ms)/done/nextState`.
- `ModuleSequence` (el vell dispatcher) eliminat. Despatxa via `game_state_ == 0` (gameplay → `ModuleGame`) o `game_state_ == 1` (cinemàtica → `SceneRegistry::tryCreate(num_piramide)`).
**Escenes migrades** (totes registrades a `Director::init` via `SceneRegistry`):
- `MortScene` (state 100) · `BannerScene` (2..5) · `MenuScene` (0) · `SlidesScene` (1, 7)
- `CreditsScene` (8) · `SecretaScene` (6) · `IntroNewLogoScene` (255, `use_new_logo=true`)
- `IntroScene` (255, `use_new_logo=false`) · `IntroSpritesScene` (sub-escena de les dues intros)
**Files d'`Options::game` exposats per a tests ràpids** (persistits a `config.yaml`):
`piramide_inicial`, `habitacio_inicial`, `vides`, `diamants_inicial`, `diners_inicial`, `use_new_logo`, `show_title_credits`.
**La capa `scenes::`** ([source/scenes/](source/scenes/)): `scene.hpp` (interfície), `scene_registry.hpp/.cpp`, `timeline`, `sprite_mover`, `frame_animator`, `palette_fade`, `surface_handle`, `scene_utils` (`playMusic`). Pures tick-based, zero while, zero `JG_ShouldUpdate`.
### Modernization Targets
**Invariants to preserve** (touch these and you broke the game):
- Gameplay feel, movement speed, enemy AI behavior
- Collision detection, scoring, lives, level progression
- Visible animation cadence (once translated to ms, must look identical)
- Difficulty curves and cinematic timings
- Cheat codes (`reviu`, `alone`, `obert`)
- Original palettes, fades, music cues
**Free to change** (internal representation):
- Data structures (structs → classes with RAII)
- Ownership (raw pointers → `std::unique_ptr`/`std::vector`/`std::string`)
- Timing representation (frame counters → ms accumulators)
- Threading model (game thread → single-threaded state machine)
- Global state (the old `info::` namespace is now an `inline` singleton `info::ctx` of type `GameContext`; access is `info::ctx.X` instead of `info::X`. Can be reset with `info::ctx.reset()`)
- API shapes of jail subsystems (as long as callers are updated consistently)
### Boundary: Original vs New Code ### Boundary: Original vs New Code
| Path | Owner | Rule | | Path | Owner | Rule |
|------|-------|------| |------|-------|------|
| `source/core/jail/` | Original engine | **Do not modify** gameplay behavior | | `source/core/jail/` | Legacy engine, modernization target | Free to modify with care — preserve external behavior |
| `source/game/*.cpp/hpp` (except options/defines/defaults) | Original game | **Do not modify** | | `source/game/*.cpp/hpp` | Legacy gameplay, modernization target | Free to modify with care — preserve gameplay invariants |
| `source/core/rendering/` | New presentation layer | Free to modify | | `source/core/rendering/` | New presentation layer | Free to modify |
| `source/core/input/` | New input layer | Free to modify | | `source/core/input/` | New input layer | Free to modify |
| `source/utils/` | New utilities | Free to modify | | `source/utils/` | New utilities | Free to modify |
| `source/game/options,defines,defaults` | New config system | Free to modify | | `source/game/options,defines,defaults` | New config system | Free to modify |
| `data/*.gif, *.ogg` | Original assets | **Do not modify** | | `data/gfx/, data/music/` | Original assets | **Do not modify** — assets remain untouchable |
| `data/fonts/, data/ui/, data/shaders/` | New assets | Free to modify | | `data/fonts/, data/shaders/, data/locale/` | New assets | Free to modify |
### Original "Jail" Engine (`source/core/jail/`) ### Legacy "Jail" Engine (`source/core/jail/`) — modernization target
Flat C-style APIs (no classes), prefixed by subsystem. **Do not touch gameplay logic.** Flat C-style APIs (no classes), prefixed by subsystem. Being progressively converted to idiomatic C++ (see Phase 1 of the plan). External API names are kept stable during the transition to avoid churning call sites.
- **JG** (`jgame`) — Game loop timing: init/finalize, fixed-timestep update via `JG_ShouldUpdate()` - **JG** (`jgame`) — Game loop timing: init/finalize, fixed-timestep update via `JG_ShouldUpdate()`
- **JD8** (`jdraw8`) — 8-bit paletted software renderer. 320x200 screen buffer (`JD8_Surface` = `Uint8*`), palette-indexed blitting with color-key transparency, fade effects. `JD8_Flip()` converts palette→ARGB and delegates to `Screen::present()` - **JD8** (`jdraw8`) — 8-bit paletted software renderer. 320x200 screen buffer (`JD8_Surface` = `Uint8*`), palette-indexed blitting with color-key transparency, fade effects. `JD8_Flip()` converts palette→ARGB and delegates to `Screen::present()`
@@ -53,7 +101,7 @@ Flat C-style APIs (no classes), prefixed by subsystem. **Do not touch gameplay l
### System Layer (`source/core/system/`) ### System Layer (`source/core/system/`)
- **Director** (`director.hpp/cpp`) — **Orchestrator singleton**. Owns main thread. Launches game thread that runs `ModuleGame`/`ModuleSequence::Go()`. Emulator-style architecture: Director runs at ~60 FPS independently, polls SDL events, updates overlay, presents frames. Game thread blocks at `JD8_Flip()` `Director::publishFrame()` until Director consumes the frame. Director is **non-blocking**: if no new frame is available, it re-presents the last known game frame with fresh overlay on top - **Director** (`director.hpp/cpp`) — **Orchestrator singleton**, únic thread del runtime. Posseeix l'estat d'escena (`current_scene_: unique_ptr<Scene>`, `game_state_`, `last_tick_ms_`) directament com a members. `iterate()` fa: poll events (via `SDL_AppEvent`) → input (Gamepad/KeyRemap/GlobalInputs/Mouse) → `JA_Update` → transició d'escena si `done()``scene->tick(delta_ms)` `JD8_Flip` (converteix `screen``pixel_data`) → overlay → present → `SDL_Delay` al frame target. Dispatcher: `game_state_ == 0``new ModuleGame`, `game_state_ == 1``SceneRegistry::tryCreate(info::ctx.num_piramide)` (amb redirect `num_piramide == 6 && diners < 200 → 7` replicant el vell `ModuleSequence::Go`).
### Presentation Layer (`source/core/rendering/`) ### Presentation Layer (`source/core/rendering/`)
@@ -65,9 +113,10 @@ Flat C-style APIs (no classes), prefixed by subsystem. **Do not touch gameplay l
### Input Layer (`source/core/input/`) ### Input Layer (`source/core/input/`)
- **GlobalInputs** (`global_inputs.hpp/cpp`) — Maps configurable function keys to presentation actions. Uses debounce. Returns whether a key was consumed (to suppress from game layer) - **KeyConfig** (`key_config.hpp/cpp`) — **Font única de veritat per a les tecles d'UI/sistema**. Carrega `data/input/keys.yaml` al boot (12 entrades: F1-F10 GlobalInputs + F11 pausa + F12 menú de servei) i opcionalment aplica overrides des de `~/.config/jailgames/aee/keys.yaml`. Exposa `KeyConfig::scancode("id")`, `scancodePtr("id")` (per a Menu KeyBind), `setScancode(...)`, `isGuiKey(sc)` (filtre del Director per a no propagar tecles d'UI a `JI_AnyKey`). `saveOverrides()` només persistix les entrades que difereixen del default. Les tecles de moviment del jugador NO viuen ací — es queden a `Options::keys_game`
- **GlobalInputs** (`global_inputs.hpp/cpp`) — Maps function keys (via `KeyConfig::scancode("dec_zoom")`, etc.) to presentation actions. Uses debounce. Returns whether a key was consumed (to suppress from game layer)
- **Mouse** (`mouse.hpp/cpp`) — Auto-hides cursor after 3 seconds of inactivity - **Mouse** (`mouse.hpp/cpp`) — Auto-hides cursor after 3 seconds of inactivity
- **Gamepad** (`gamepad.hpp/cpp`) — First-gamepad support with hot-plug. Poll-based each frame: D-pad/left stick (deadzone 12000) → virtual arrow keys for game movement; A/B buttons, Start, Back translate to synthetic SDL key events (F12/ESC/Enter/Backspace) when menu is open, so Director handles them exactly like keyboard. Loads extra mappings from `gamecontrollerdb.txt` (next to the executable) at init via `SDL_AddGamepadMappingsFromFile`, extending SDL's built-in controller database - **Gamepad** (`gamepad.hpp/cpp`) — First-gamepad support with hot-plug + overlay notification with controller name. Poll-based each frame: D-pad/left stick (deadzone 12000) → virtual arrow keys for game movement. Mapeig: SOUTH/EAST/WEST/NORTH (4 botons frontals) → Enter sintètic per avançar escenes; al menú EAST=accept, SOUTH=cancel/back. SELECT → menu_toggle (servei), START → pause_toggle (via `KeyConfig::scancode(...)`). Loads extra mappings from `gamecontrollerdb.txt` at init via `SDL_AddGamepadMappingsFromFile`
- **KeyRemap** (`key_remap.hpp/cpp`) — Each frame, reads `Options::keys_game.*` and mirrors physical keyboard state to virtual standard scancodes (`SDL_SCANCODE_UP`/DOWN/LEFT/RIGHT). Allows full movement key remapping without touching hardcoded game code in `prota.cpp`/`mapa.cpp` - **KeyRemap** (`key_remap.hpp/cpp`) — Each frame, reads `Options::keys_game.*` and mirrors physical keyboard state to virtual standard scancodes (`SDL_SCANCODE_UP`/DOWN/LEFT/RIGHT). Allows full movement key remapping without touching hardcoded game code in `prota.cpp`/`mapa.cpp`
### Locale Layer (`source/core/locale/`) ### Locale Layer (`source/core/locale/`)
@@ -79,8 +128,8 @@ Flat C-style APIs (no classes), prefixed by subsystem. **Do not touch gameplay l
Follows the pattern from `jaildoctors_dilemma`, persists to YAML: Follows the pattern from `jaildoctors_dilemma`, persists to YAML:
- **defines.hpp** — Game constants: `Texts::WINDOW_TITLE`, `Texts::VERSION`, `GameScreen::WIDTH/HEIGHT` - **defines.hpp** — Game constants: `Texts::WINDOW_TITLE`, `Texts::VERSION`, `GameScreen::WIDTH/HEIGHT`
- **defaults.hpp** — Default values: `Defaults::KeysGUI`, `Defaults::KeysGame`, `Defaults::Video`, `Defaults::Audio`, `Defaults::Window`, `Defaults::Game` - **defaults.hpp** — Default values: `Defaults::KeysGame`, `Defaults::Video`, `Defaults::Audio`, `Defaults::Window`, `Defaults::Game`. (Les tecles d'UI viuen a `data/input/keys.yaml` via `KeyConfig`)
- **options.hpp/cpp** — `Options` namespace with inline globals and YAML load/save. Structs: `KeysGUI`, `KeysGame`, `Video`, `RenderInfo`, `Audio`, `Window`, `Game`, `PostFXPreset`, `CrtPiPreset` - **options.hpp/cpp** — `Options` namespace with inline globals and YAML load/save. Structs: `KeysGame`, `Video`, `RenderInfo`, `Audio`, `Window`, `Game`, `PostFXPreset`, `CrtPiPreset`
### Utilities (`source/utils/`) ### Utilities (`source/utils/`)
@@ -99,40 +148,58 @@ Follows the pattern from `jaildoctors_dilemma`, persists to YAML:
| F6 | Toggle supersampling | | F6 | Toggle supersampling |
| F7 | Cycle shader type (PostFX ↔ CRT-Pi) | | F7 | Cycle shader type (PostFX ↔ CRT-Pi) |
| F8 | Cycle shader presets | | F8 | Cycle shader presets |
| F9 | Toggle stretch filter (nearest ↔ linear) | | F9 | Cycle texture filter (nearest ↔ linear) — sempre aplicat, independent de 4:3 |
| F10 | Cycle render info (off → top → bottom → off) | | F10 | Cycle render info (off → top → bottom → off) |
| F11 | Toggle pause (blocks game thread at publishFrame + `JA_PauseMusic`/`JA_ResumeMusic`) | | F11 | Toggle pause (Director skips `scene->tick()` + `JA_PauseMusic`/`JA_ResumeMusic`) |
| F12 | Toggle floating options menu | | F12 | Toggle floating options menu |
| ESC | Double-press to quit (with overlay notification) / close menu if open | | ESC | Double-press to quit (with overlay notification) / close menu if open |
| Backspace | Go up one menu level / close menu if at root | | Backspace | Go up one menu level / close menu if at root |
| ↑↓←→ / Enter | Menu navigation | | ↑↓←→ / Enter | Menu navigation |
All key bindings are configurable via `Options::keys_gui` and stored in `config.yaml` (section `controls:` with SDL scancode names). Game movement keys (`Options::keys_game.up/down/left/right`) can be remapped via the CONTROLS submenu — the `KeyRemap` module mirrors custom physical keys to virtual standard scancodes so hardcoded game code keeps working. UI/system key bindings are loaded from [data/input/keys.yaml](data/input/keys.yaml) via `KeyConfig`. Overrides fets des del menú es persistixen a `~/.config/jailgames/aee/keys.yaml` (només les que difereixen del default). Game movement keys (`Options::keys_game.up/down/left/right`) viuen separadament a `config.yaml` (secció `controls:`) i es remapejen via la CONTROLS submenu — el `KeyRemap` module mirrors custom physical keys to virtual standard scancodes so hardcoded game code keeps working.
### Threading Model (Emulator Architecture) ### Execution Model (Single-threaded, Scene-based)
Zero threads, zero fibers, zero mutex. Main loop via SDL3 Callback API (`SDL_MAIN_USE_CALLBACKS`) a [main.cpp](source/main.cpp). Cada frame entra pel `SDL_AppIterate``Director::iterate()`:
``` ```
Main thread (Director) Game thread (ModuleGame/Sequence::Go()) SDL_AppIterate → Director::iterate() {
──────────────────── ──────────────────────────────────── if (quit_requested_) { scene.reset(); return false; }
loop at ~60 FPS { loop { if (!context_initialized_) initGameContext();
SDL_PollEvent() ... game logic ...
GlobalInputs, Mouse JD8_Flip(): Gamepad/KeyRemap/GlobalInputs/Mouse::update
if new_frame_available: palette→ARGB in pixel_data JA_Update() ← audio pump
copy to game_frame publishFrame(pixel_data) ⏸
signal → ────────────────────→ (blocks until Director consumes) if (!paused_) {
copy game_frame → present_buffer ←──── signal_consumed if (scene && (scene->done() || JG_Quitting()))
Overlay::render(present_buffer) continue game loop game_state_ = scene->nextState(); scene.reset();
Screen::present(present_buffer) } if (!scene) {
SDL_Delay to hit 60fps if (game_state_ == -1 || JG_Quitting()) return false;
scene = createNextScene(); ← ModuleGame o registry.tryCreate()
scene->onEnter();
} }
JI_Update()
scene->tick(now - last_tick_ms_)
JD8_Flip() ← converteix screen indexat → pixel_data
memcpy pixel_data → game_frame
}
memcpy game_frame → presentation_buffer
Overlay::render(presentation_buffer)
Screen::present(presentation_buffer)
SDL_Delay(frame_target - elapsed)
}
SDL_AppEvent → Director::handleEvent() ← events lliurats un a un per SDL
SDL_AppQuit → Director::teardown() ← Options::save + descàrrega ordenada
``` ```
**Key points:** **Key points:**
- Director is NON-BLOCKING: if no new frame, re-presents the last one with fresh overlay - `Director` posseeix `current_scene_`, `game_state_`, `last_tick_ms_`, `context_initialized_` com a members — abans vivien al stack del fiber.
- Double buffer: `game_frame` (untouched copy from game) + `presentation_buffer` (regenerated with overlay each frame) - `JD8_Flip()` només converteix paleta + `screen` a ARGB (`pixel_data`). Ja **no fa yield** — tot corre lineal.
- Game thread pauses at `JD8_Flip()` waiting for Director — natural sync point - `JG_ShouldUpdate()` encara existeix a `jgame.cpp` com a timing-gate per a `ModuleGame::Update()` (10 ms fix), però ja no fa yield. Cap caller fa spin-wait.
- SDL events processed ONLY on main thread (SDL requirement) - Pausa (F11) simplement salta el bloc de tick; overlay i present continuen, es re-presenta l'últim frame congelat.
- `JI_Update()` no longer polls events — reads Director's state - Doble buffer (`game_frame` + `presentation_buffer`) es manté perquè el Director pot presentar múltiples frames per cada tick durant pausa; el cost (256 KB memcpy) és trivial a 320×200.
- SDL3 Callback API compatible amb emscripten: el navegador posseeix el main loop i ens crida via `requestAnimationFrame`. Zero canvis de codi per a portabilitat.
### Rendering Pipeline (inside Screen::present) ### Rendering Pipeline (inside Screen::present)
@@ -158,10 +225,40 @@ JD8_Flip produces ABGR byte order: `0xFF000000 + R + (G<<8) + (B<<16)`. SDL text
| File | Content | | File | Content |
|------|---------| |------|---------|
| `~/.config/jailgames/aee/config.yaml` | Main config: video (incl. `vsync`, `integer_scale`), audio (incl. `enabled` master + `music_*` + `sound_*`), window, render_info (incl. `show_time`), game, shader selection, controls (movement keys + menu_toggle + pause_toggle) | | `~/.config/jailgames/aee/config.yaml` | Main config: video (incl. `vsync`, `scaling_mode`, `texture_filter`), audio (incl. `enabled` master + `music_*` + `sound_*`), window, render_info (incl. `show_time`), game, shader selection, controls (només moviment del jugador) |
| `~/.config/jailgames/aee/keys.yaml` | UI key overrides (només entrades que difereixen del default de [data/input/keys.yaml](data/input/keys.yaml)). Generat per `KeyConfig::saveOverrides()` |
| `~/.config/jailgames/aee/postfx.yaml` | PostFX shader presets (6 defaults: CRT, NTSC, CURVED, SCANLINES, SUBTLE, CRT LIVE) | | `~/.config/jailgames/aee/postfx.yaml` | PostFX shader presets (6 defaults: CRT, NTSC, CURVED, SCANLINES, SUBTLE, CRT LIVE) |
| `~/.config/jailgames/aee/crtpi.yaml` | CRT-Pi shader presets (4 defaults: DEFAULT, CURVED, SHARP, MINIMAL) | | `~/.config/jailgames/aee/crtpi.yaml` | CRT-Pi shader presets (4 defaults: DEFAULT, CURVED, SHARP, MINIMAL) |
### Resource Pack (`source/core/resources/`)
Sistema d'empaquetat d'assets a l'estil `coffee_crisis_arcade_edition`. Genera un sol fitxer binari opac `resources.pack` que substitueix la carpeta `data/` als releases natius.
**Format AEE1** (fidel a CCAE amb clau pròpia):
```
Header: "AEE1" (4B) + version uint32 + resource_count uint32
Index: per recurs → filename_len uint32 + filename + offset uint64 + size uint64 + checksum uint32
Payload: data_size uint64 + bytes XOR-xifrats amb "AEE_RESOURCES__2026"
```
Checksum: djb2-like amb seed `0x12345678`. Càrrega full-to-RAM (sense mmap).
**Fitxers**:
- [source/core/resources/resource_pack.hpp/cpp](source/core/resources/) — classe `ResourcePack`: `loadPack`, `savePack`, `addFile`, `addDirectory`, `getResource(name) → std::vector<uint8_t>`, `hasResource`
- [source/core/resources/resource_helper.hpp/cpp](source/core/resources/) — namespace `ResourceHelper`: `initializeResourceSystem(pack, enable_fallback)`, `loadFile(relative_path)`, `shutdownResourceSystem`. Prova el pack primer, cau a `file_getresourcefolder()+path` si el fallback està actiu.
- [tools/pack_resources/pack_resources.cpp](tools/pack_resources/pack_resources.cpp) — eina standalone CLI: `pack_resources [input_dir=data] [output=resources.pack]` + `--list pack`.
**Build**:
- `make pack` compila l'eina (target `pack_resources` a `EXCLUDE_FROM_ALL` de [CMakeLists.txt](CMakeLists.txt)) i genera `resources.pack` a la rel. 33 entrades ≈ 4 MB.
- `./build/pack_resources --list resources.pack` inspecciona el pack.
**Estat actual (Fases 1-6 completades, 2026-04-16)**:
- `ResourcePack` + `ResourceHelper` + eina `pack_resources` compilen i funcionen. El pack genera 33 entrades ≈ 4 MB.
- Cablejat al joc via `ResourceHelper::initializeResourceSystem` a [main.cpp](source/main.cpp) (amb `return SDL_APP_FAILURE` si falla), i `shutdownResourceSystem` a `SDL_AppQuit`.
- Tots els callsites de recursos usen `ResourceHelper::loadFile` (`std::vector<uint8_t>`): [locale.cpp](source/core/locale/locale.cpp), [text.cpp](source/core/rendering/text.cpp), [scene_utils.cpp](source/scenes/scene_utils.cpp), [modulegame.cpp](source/game/modulegame.cpp), [jdraw8.cpp](source/core/jail/jdraw8.cpp).
- Scaffold `.jrf` eliminat de [jfile.cpp](source/core/jail/jfile.cpp): `file_setresourcefilename`, `file_setsource`, `SOURCE_FILE`/`SOURCE_FOLDER`, `dictionary_loaded`, `file_getfilepointer`, `file_readfile`. Només queden config-folder i resource-folder getters/setters.
- Targets release a [Makefile](Makefile) (`_linux_release`/`_windows_release`/`_macos_release`) depenen de `pack` i copien `resources.pack` en lloc de `data/`. WASM intacte (`--preload-file data@/data`).
- `enable_fallback = false` a Release natiu (`NDEBUG && !__EMSCRIPTEN__`): el pack és obligatori. Debug i WASM mantenen el fallback actiu.
### External Libraries (`source/external/`) ### External Libraries (`source/external/`)
- `gif.h` — Header-only GIF decoder. **Cannot be included from more than one .cpp** (no include guards on functions). Other files use `extern` declarations for `LoadGif()` - `gif.h` — Header-only GIF decoder. **Cannot be included from more than one .cpp** (no include guards on functions). Other files use `extern` declarations for `LoadGif()`
@@ -170,20 +267,41 @@ JD8_Flip produces ABGR byte order: `0xFF000000 + R + (G<<8) + (B<<16)`. SDL text
### Data Assets (`data/`) ### Data Assets (`data/`)
- `*.gif`, `*.ogg` — Original game assets (**do not modify**) - `gfx/` — Original game GIFs (**do not modify content**): `frames.gif`/`frames2.gif` (sprite sheet del joc), `logo.gif`/`logo_new.gif` (intros), `menu.gif`/`menu2.gif`, `intro.gif`/`intro2.gif`/`intro3.gif` (slides), `ffase.gif` (banner nivells), `final.gif`/`finals.gif` (crèdits), `gameover.gif`, `tomba1.gif`/`tomba2.gif` (escena secreta)
- `music/` — 8 pistes OGG originals amb noms temàtics: `mort.ogg` (game over), `secreta.ogg` (escena secreta + piràmide 6), `menu.ogg` (menú + intros), `banner.ogg` (banner de fase), `final.ogg` (slides finals + crèdits), `piramide_1_4_5.ogg` (gameplay default), `piramide_2.ogg`, `piramide_3.ogg`
- `fonts/8bithud.fnt + .gif` — Bitmap font for overlay (8×8, 124 glyphs, UTF-8 with accents) - `fonts/8bithud.fnt + .gif` — Bitmap font for overlay (8×8, 124 glyphs, UTF-8 with accents)
- `shaders/` — GLSL sources: `postfx.vert`, `postfx.frag`, `upscale.frag`, `downscale.frag`, `crtpi_frag.glsl` - `shaders/` — GLSL sources: `postfx.vert`, `postfx.frag`, `upscale.frag`, `downscale.frag`, `crtpi_frag.glsl`
- `locale/ca.yaml` — UI strings in Valencian (menu titles/items/values, notifications). Edit freely; reload at restart - `locale/ca.yaml` — UI strings in Valencian (menu titles/items/values, notifications). Edit freely; reload at restart
- `ui/` — Reserved for future UI graphics
### Known Issues & Technical Debt ### Known Issues & Technical Debt
1. **gif.h cannot be included twice**: Functions are not `static` or `inline`, causing multiple definition errors. Text class uses `extern` forward declarations as workaround 1. **gif.h cannot be included twice**: Functions are not `static` or `inline`, causing multiple definition errors. Text class uses `extern` forward declarations as workaround
2. **Cheats are broken (`reviu`, `alone`, `obert`)**: `JI_CheatActivated` in [jinput.cpp:46](source/core/jail/jinput.cpp#L46) compares `SDL_Scancode` values (e.g. `SDL_SCANCODE_R`=21) against ASCII chars (`'r'`=114). They never match. Regression from SDL3 migration. Fix requires either scancode→char conversion in `JI_moveCheats` or storing chars directly. 2. ~~**Cheats are broken (`reviu`, `alone`, `obert`)**~~: Fixed. `JI_moveCheats` tradueix `SDL_Scancode` → ASCII via `scancode_to_ascii` abans de ficar-los al buffer ([jinput.cpp:32-37, 55-61](source/core/jail/jinput.cpp#L32-L61)), i `JI_CheatActivated` compara ASCII amb ASCII.
3. **No sound effects in game**: Game code never calls `JA_PlaySound*`/`JA_LoadSound` — only music via `JA_PlayMusic`/`JA_FadeOutMusic`. The SONS and VOL SONS menu items control volume of an empty channel pool. Infrastructure ready for future SFX. 3. **No sound effects in game**: Game code never calls `JA_PlaySound*`/`JA_LoadSound` — only music via `JA_PlayMusic`/`JA_FadeOutMusic`. The SONS and VOL SONS menu items control volume of an empty channel pool. Infrastructure ready for future SFX.
4. ~~**Raw `malloc`/`free` in gameplay structs**~~: Majoritàriament arreglat. `Sprite`/`Entitat` usen `std::vector<Frame>` i `std::vector<Animacio>` ([sprite.hpp](source/game/sprite.hpp)). `jfile.cpp` ja no té el global `scratch[255]` (substituït per `thread_local std::string`). L'API `file_getfilebuffer` (que tornava raw `char*` amb `malloc`) s'ha substituït per `file_readfile` que retorna `std::vector<char>` RAII — elimina els leaks silenciosos que hi havia a `JD8_LoadPalette`, `ModuleGame::Go()` i `scenes::playMusic`. Queda `jail_audio.hpp` barrejant `new`/`malloc`/`SDL_malloc` de forma pairada i correcta (no leak), pendent de polir amb `std::unique_ptr` quan toque.
5. ~~**Blocking loops in cinematics and fades**~~: Fixed. Migració completa de `ModuleSequence::do*()` a la capa `scenes::` (Steps 110) + `ModuleGame` també tick-based (Phase A). Tot `while(!JG_ShouldUpdate())` i `wait_frame_or_skip()` eliminat. Els fades bloquejants `JD8_FadeOut`/`JD8_FadeToPal` també eliminats (Phase B.2): només queda l'API tick-step `JD8_FadeStart*` + `JD8_FadeTickStep`, encapsulada pel wrapper `scenes::PaletteFade`. ModuleGame té fases `FadingIn`/`FadingOut` pròpies.
6. ~~**`SDL_AddTimer` in audio**~~: Fixed in Phase 3. `jail_audio` is now a header-only `inline` module (single `.cpp` stub only hosts the `stb_vorbis` implementation to avoid multiple definitions). Music uses true streaming via `stb_vorbis_open_memory` + `JA_PumpMusic` with a 0.5s low-water-mark instead of decoding the whole OGG into RAM. Mixing/fade update runs manually via `JA_Update()` called once per frame from `Director::iterate()`. Ported from the `jaildoctors_dilemma` codebase.
7. ~~**Game thread + `publishFrame` mutex/cv**~~: Fixed in Phase 4+5 via cooperative `GameFiber`; **eliminated entirely in Phase B.2**. `JD8_Flip()` ja no fa yield — només converteix `screen``pixel_data`. Director posseeix l'estat d'escena (`current_scene_`, `game_state_`) i crida `scene->tick()` directament des d'`iterate()`. Fitxers `source/core/system/fiber.{hpp,cpp}` esborrats. Zero threads, zero mutex, zero fibers.
8. ~~**`ModuleSequence` legacy dispatcher**~~: Eliminated in Step 10. Era el vell switch per `num_piramide`, ara substituït per `SceneRegistry::tryCreate()` i dispatch directe des de `Director::iterate()`. `modulesequence.{hpp,cpp}` esborrats.
### WebAssembly Build
`make wasm` genera el build WASM via Docker (`emscripten/emsdk:latest`) i copia els 3 fitxers (`.js`/`.wasm`/`.data`) a `maverick:/home/sergio/gitea/web_jailgames/static/games/aee/wasm/`, amb un `ssh maverick './deploy.sh'` final. Output local a `dist/wasm/`.
**Diferències respecte build natiu** (a [CMakeLists.txt](CMakeLists.txt) dins `if(EMSCRIPTEN)`):
- SDL3 compilat des de font via `FetchContent` (no hi ha paquet de sistema).
- Shaders SPIR-V omesos (SDL3 GPU no suportat a WebGL2).
- `sdl3gpu_shader.cpp` exclòs dels sources — el fallback `SDL_Renderer` fa tota la presentació.
- [screen.cpp](source/core/rendering/screen.cpp) guarda `#ifndef NO_SHADERS` al voltant de l'include i les crides a `SDL3GPUShader` directes. La resta del codi va via interfície base `ShaderBackend`.
- Link flags: `--preload-file data@/data`, `-fexceptions`, `-sALLOW_MEMORY_GROWTH=1`, `-sMAX_WEBGL_VERSION=2`, `-sINITIAL_MEMORY=67108864`, `-sASSERTIONS=1`, `-sASYNCIFY=1`.
- Defines: `EMSCRIPTEN_BUILD`, `NO_SHADERS`.
**Filesystem**: MEMFS default — no persistent entre recàrregues. `file_setconfigfolder` té fallbacks robustos (`getpwuid``getenv("HOME")``/tmp`) perquè no pete quan emscripten no té `/etc/passwd`. La config es carrega per defecte cada vegada. IDBFS pendent si mai volguéssem persistència a web.
### Pending / Ideas for Later ### Pending / Ideas for Later
- **Sound effects**: infraestructura `JA_PlaySound*`/`JA_LoadSound` ja preparada. Cablejar events de gameplay (col·lisió momia, mort, recollir objecte, trencar tomba, cheat activat). Menus SONS/VOL SONS ja controlen el volum del pool.
- **IDBFS persistence a WASM**: montar `/home/web_user/.config` com a IDBFS a l'init i `FS.syncfs` després de cada save. Opcional — ara per ara la config no persistix entre recàrregues de pàgina.
- **Gamepad**: map Y button (North) to `P` key for Pepe character selection at title screen (only input path not covered by current mapping). - **Gamepad**: map Y button (North) to `P` key for Pepe character selection at title screen (only input path not covered by current mapping).
- **Menu items for game**: habitacio_inicial, piramide_inicial, vides (already in `Options::game`, not exposed). - **Menu items for game**: habitacio_inicial, piramide_inicial, vides (already in `Options::game`, not exposed).
- **Multi-language**: add `data/locale/es.yaml` / `en.yaml` and a language selector item in menu. `Locale::load()` already handles arbitrary files. - **Multi-language**: add `data/locale/es.yaml` / `en.yaml` and a language selector item in menu. `Locale::load()` already handles arbitrary files.
@@ -191,6 +309,7 @@ JD8_Flip produces ABGR byte order: `0xFF000000 + R + (G<<8) + (B<<16)`. SDL text
- **Notification persistence**: notifications clear on each new one (`showNotification` does `notifications_.clear()`). Could queue instead. - **Notification persistence**: notifications clear on each new one (`showNotification` does `notifications_.clear()`). Could queue instead.
- **FPS counter jitter**: time segment width changes per frame (100 Hz centi updates) causes ~1-2 px horizontal jitter in centered layout. Could lock to max-width or use monospace digits. - **FPS counter jitter**: time segment width changes per frame (100 Hz centi updates) causes ~1-2 px horizontal jitter in centered layout. Could lock to max-width or use monospace digits.
- **Notification messages partially hardcoded**: overlay/global_inputs/director now use Locale, but the window title (`Texts::WINDOW_TITLE`) and some game-layer strings remain hardcoded. - **Notification messages partially hardcoded**: overlay/global_inputs/director now use Locale, but the window title (`Texts::WINDOW_TITLE`) and some game-layer strings remain hardcoded.
- **jail_audio `JA_Sound_t` RAII**: `JA_Music_t` ja està net (vector + string), però `JA_Sound_t` encara usa `Uint8*` via `SDL_LoadWAV` out-param. Petit polish per a completar la coherència RAII.
### Previously Fixed (kept for reference) ### Previously Fixed (kept for reference)
@@ -212,4 +331,4 @@ Director loop uses `FRAME_MS_VSYNC = 16` (60 FPS) or `FRAME_MS_NO_VSYNC = 4` (~2
Init order: `file_setconfigfolder``Options::load``Locale::load("locale/ca.yaml")``Options::loadPostFX/CrtPi``JG_Init``Screen::init``JD8_Init``JA_Init``Options::applyAudio()``Overlay::init``Menu::init``Director::init` (also calls `Gamepad::init()`) → `Director::run()` (blocks until quit). Shutdown: `Options::save``Director::destroy``Menu::destroy``Overlay::destroy``JA_Quit``JD8_Quit``Screen::destroy``JG_Finalize`. Init order: `file_setconfigfolder``Options::load``Locale::load("locale/ca.yaml")``Options::loadPostFX/CrtPi``JG_Init``Screen::init``JD8_Init``JA_Init``Options::applyAudio()``Overlay::init``Menu::init``Director::init` (also calls `Gamepad::init()`) → `Director::run()` (blocks until quit). Shutdown: `Options::save``Director::destroy``Menu::destroy``Overlay::destroy``JA_Quit``JD8_Quit``Screen::destroy``JG_Finalize`.
The state machine (alternating `ModuleSequence` state=1 and `ModuleGame` state=0) now lives inside `Director::gameThreadFunc()`, running on the game thread. The state machine (alternating `ModuleSequence` state=1 and `ModuleGame` state=0) lives inside `gameFiberEntry()` in an anonymous namespace in [director.cpp](source/core/system/director.cpp), invoked as the entry of the cooperative fiber (single-threaded).

View File

@@ -3,6 +3,11 @@
cmake_minimum_required(VERSION 3.10) cmake_minimum_required(VERSION 3.10)
project(aee VERSION 1.00) project(aee VERSION 1.00)
# Tipus de build per defecte (Debug) si no se n'ha especificat cap
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE Debug CACHE STRING "" FORCE)
endif()
# Estándar de C++ # Estándar de C++
set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED True) set(CMAKE_CXX_STANDARD_REQUIRED True)
@@ -10,18 +15,50 @@ set(CMAKE_CXX_STANDARD_REQUIRED True)
# Exportar comandos de compilación para herramientas de análisis # Exportar comandos de compilación para herramientas de análisis
set(CMAKE_EXPORT_COMPILE_COMMANDS ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
# --- GENERACIÓ AUTOMÀTICA DE VERSIÓ ---
# Si GIT_HASH ve de fora (p. ex. el Makefile via -DGIT_HASH=xxx), l'usem tal
# qual. Això evita problemes amb Docker/emscripten on git avorta per
# "dubious ownership" al volum muntat. En builds locals sense -DGIT_HASH
# resolem ací executant git directament.
if(NOT DEFINED GIT_HASH OR GIT_HASH STREQUAL "")
find_package(Git QUIET)
if(GIT_FOUND)
execute_process(
COMMAND ${GIT_EXECUTABLE} rev-parse --short=7 HEAD
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
OUTPUT_VARIABLE GIT_HASH
OUTPUT_STRIP_TRAILING_WHITESPACE
ERROR_QUIET
)
endif()
if(NOT DEFINED GIT_HASH OR GIT_HASH STREQUAL "")
set(GIT_HASH "unknown")
endif()
endif()
configure_file(${CMAKE_SOURCE_DIR}/source/version.h.in ${CMAKE_BINARY_DIR}/version.h @ONLY)
# --- LISTA EXPLÍCITA DE FUENTES --- # --- LISTA EXPLÍCITA DE FUENTES ---
set(APP_SOURCES set(APP_SOURCES
# Core - Motor original "Jail" (no tocar gameplay) # Core - Motor original "Jail" (no tocar gameplay)
source/core/jail/jail_audio.cpp
source/core/jail/jdraw8.cpp source/core/jail/jdraw8.cpp
source/core/jail/jfile.cpp source/core/jail/jfile.cpp
source/core/jail/jgame.cpp source/core/jail/jgame.cpp
source/core/jail/jinput.cpp source/core/jail/jinput.cpp
# Core - Audio (wrapper canònic compartit amb la resta de projectes)
source/core/audio/audio.cpp
source/core/audio/audio_adapter.cpp
# Core - Locale (nova capa) # Core - Locale (nova capa)
source/core/locale/locale.cpp source/core/locale/locale.cpp
# Core - Resources (pack binari AEE1 + cache d'assets precarregats)
source/core/resources/resource_pack.cpp
source/core/resources/resource_helper.cpp
source/core/resources/resource_list.cpp
source/core/resources/resource_cache.cpp
# Core - Capa de presentación (nueva) # Core - Capa de presentación (nueva)
source/core/rendering/menu.cpp source/core/rendering/menu.cpp
source/core/rendering/overlay.cpp source/core/rendering/overlay.cpp
@@ -34,12 +71,32 @@ set(APP_SOURCES
# Core - Input (nova capa) # Core - Input (nova capa)
source/core/input/gamepad.cpp source/core/input/gamepad.cpp
source/core/input/global_inputs.cpp source/core/input/global_inputs.cpp
source/core/input/key_config.cpp
source/core/input/key_remap.cpp source/core/input/key_remap.cpp
source/core/input/mouse.cpp source/core/input/mouse.cpp
# Core - System (nova capa) # Core - System (nova capa)
source/core/system/director.cpp source/core/system/director.cpp
# Scenes (cinemàtiques i menús reescrits)
source/scenes/timeline.cpp
source/scenes/sprite_mover.cpp
source/scenes/frame_animator.cpp
source/scenes/palette_fade.cpp
source/scenes/surface_handle.cpp
source/scenes/scene_registry.cpp
source/scenes/scene_utils.cpp
source/scenes/boot_loader_scene.cpp
source/scenes/mort_scene.cpp
source/scenes/banner_scene.cpp
source/scenes/menu_scene.cpp
source/scenes/intro_new_logo_scene.cpp
source/scenes/intro_scene.cpp
source/scenes/intro_sprites_scene.cpp
source/scenes/slides_scene.cpp
source/scenes/credits_scene.cpp
source/scenes/secreta_scene.cpp
# Game # Game
source/game/options.cpp source/game/options.cpp
source/game/bola.cpp source/game/bola.cpp
@@ -48,7 +105,6 @@ set(APP_SOURCES
source/game/mapa.cpp source/game/mapa.cpp
source/game/marcador.cpp source/game/marcador.cpp
source/game/modulegame.cpp source/game/modulegame.cpp
source/game/modulesequence.cpp
source/game/momia.cpp source/game/momia.cpp
source/game/prota.cpp source/game/prota.cpp
source/game/sprite.cpp source/game/sprite.cpp
@@ -63,8 +119,22 @@ set(APP_SOURCES
# Configuración de SDL3 # Configuración de SDL3
# En macOS bundle mode usamos el xcframework (universal arm64+x86_64). # En macOS bundle mode usamos el xcframework (universal arm64+x86_64).
# En el resto de casos, o en macOS sin bundle, usamos SDL3 del sistema via find_package. # En emscripten compilamos SDL3 desde source con FetchContent (no hi ha paquet de sistema).
if(APPLE AND MACOS_BUNDLE) # En el resto de casos, usamos SDL3 del sistema via find_package.
if(EMSCRIPTEN)
include(FetchContent)
FetchContent_Declare(
SDL3
GIT_REPOSITORY https://github.com/libsdl-org/SDL.git
GIT_TAG release-3.4.4
GIT_SHALLOW TRUE
)
set(SDL_SHARED OFF CACHE BOOL "" FORCE)
set(SDL_STATIC ON CACHE BOOL "" FORCE)
set(SDL_TEST_LIBRARY OFF CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(SDL3)
message(STATUS "SDL3: compilat des de source per a Emscripten (FetchContent)")
elseif(APPLE AND MACOS_BUNDLE)
set(SDL3_XCFRAMEWORK_SLICE "${CMAKE_SOURCE_DIR}/release/macos/frameworks/SDL3.xcframework/macos-arm64_x86_64") set(SDL3_XCFRAMEWORK_SLICE "${CMAKE_SOURCE_DIR}/release/macos/frameworks/SDL3.xcframework/macos-arm64_x86_64")
message(STATUS "SDL3: usando xcframework (${SDL3_XCFRAMEWORK_SLICE})") message(STATUS "SDL3: usando xcframework (${SDL3_XCFRAMEWORK_SLICE})")
else() else()
@@ -72,12 +142,12 @@ else()
message(STATUS "SDL3 encontrado: ${SDL3_INCLUDE_DIRS}") message(STATUS "SDL3 encontrado: ${SDL3_INCLUDE_DIRS}")
endif() endif()
# --- COMPILACIÓ SHADERS SPIR-V (Linux/Windows — macOS usa Metal) --- # --- COMPILACIÓ SHADERS SPIR-V (Linux/Windows — macOS usa Metal, Emscripten no suporta SDL3 GPU) ---
if(NOT APPLE) if(NOT APPLE AND NOT EMSCRIPTEN)
find_program(GLSLC_EXE NAMES glslc) find_program(GLSLC_EXE NAMES glslc)
set(SHADERS_DIR "${CMAKE_SOURCE_DIR}/data/shaders") set(SHADERS_DIR "${CMAKE_SOURCE_DIR}/data/shaders")
set(HEADERS_DIR "${CMAKE_SOURCE_DIR}/source/core/rendering/sdl3gpu") set(HEADERS_DIR "${CMAKE_SOURCE_DIR}/source/core/rendering/sdl3gpu/spv")
set(ALL_SHADER_HEADERS set(ALL_SHADER_HEADERS
"${HEADERS_DIR}/postfx_vert_spv.h" "${HEADERS_DIR}/postfx_vert_spv.h"
@@ -120,21 +190,32 @@ if(NOT APPLE)
endforeach() endforeach()
message(STATUS "glslc no trobat — usant headers SPIR-V precompilats") message(STATUS "glslc no trobat — usant headers SPIR-V precompilats")
endif() endif()
elseif(EMSCRIPTEN)
message(STATUS "Emscripten: shaders SPIR-V omesos (SDL3 GPU no suportat a WebGL2)")
else() else()
message(STATUS "macOS: shaders SPIR-V omesos (usa Metal)") message(STATUS "macOS: shaders SPIR-V omesos (usa Metal)")
endif() endif()
# --- EJECUTABLE --- # --- EJECUTABLE ---
# A emscripten excloïm sdl3gpu_shader.cpp — SDL3 GPU no suporta WebGL2, i el
# fallback SDL_Renderer de Screen (amb NO_SHADERS) fa tota la presentació.
if(EMSCRIPTEN)
set(APP_SOURCES_WASM ${APP_SOURCES})
list(REMOVE_ITEM APP_SOURCES_WASM source/core/rendering/sdl3gpu/sdl3gpu_shader.cpp)
add_executable(${PROJECT_NAME} ${APP_SOURCES_WASM})
else()
add_executable(${PROJECT_NAME} ${APP_SOURCES}) add_executable(${PROJECT_NAME} ${APP_SOURCES})
endif()
# Shaders han de compilar-se abans que l'executable (Linux/Windows amb glslc) # Shaders han de compilar-se abans que l'executable (Linux/Windows amb glslc)
if(NOT APPLE AND GLSLC_EXE) if(NOT APPLE AND NOT EMSCRIPTEN AND GLSLC_EXE)
add_dependencies(${PROJECT_NAME} shaders) add_dependencies(${PROJECT_NAME} shaders)
endif() endif()
# --- DIRECTORIOS DE INCLUSIÓN --- # --- DIRECTORIOS DE INCLUSIÓN ---
target_include_directories(${PROJECT_NAME} PUBLIC target_include_directories(${PROJECT_NAME} PUBLIC
"${CMAKE_SOURCE_DIR}/source" "${CMAKE_SOURCE_DIR}/source"
"${CMAKE_BINARY_DIR}"
) )
# Enlazar SDL3 # Enlazar SDL3
@@ -159,15 +240,72 @@ target_compile_options(${PROJECT_NAME} PRIVATE $<$<CONFIG:RELEASE>:-Os -ffunctio
# --- CONFIGURACIÓN POR PLATAFORMA --- # --- CONFIGURACIÓN POR PLATAFORMA ---
if(WIN32) if(WIN32)
target_link_libraries(${PROJECT_NAME} PRIVATE mingw32) target_link_libraries(${PROJECT_NAME} PRIVATE mingw32)
elseif(EMSCRIPTEN)
target_compile_definitions(${PROJECT_NAME} PRIVATE EMSCRIPTEN_BUILD NO_SHADERS)
# -fexceptions: SDL3 i fkyaml llancen std::exception; sense això, `throw`
# acaba en `abort()`. També requerit al link per congruència ABI.
target_compile_options(${PROJECT_NAME} PRIVATE -fexceptions)
target_link_options(${PROJECT_NAME} PRIVATE
"SHELL:--preload-file ${CMAKE_SOURCE_DIR}/data@/data"
-fexceptions
-sALLOW_MEMORY_GROWTH=1
-sMAX_WEBGL_VERSION=2
-sINITIAL_MEMORY=67108864
-sASSERTIONS=1
# ASYNCIFY permet que Emscripten gestione yields durant la precarga
# d'assets. El main loop del joc ja usa SDL3 Callback API i no depén
# d'Asyncify — però el preloader del `.data` sí.
-sASYNCIFY=1
)
set_target_properties(${PROJECT_NAME} PROPERTIES SUFFIX ".html")
endif() endif()
# Ejecutable en la raíz del proyecto # Ejecutable en la raíz del proyecto (solo nativos). A Emscripten queda dins build/.
if(NOT EMSCRIPTEN)
set_target_properties(${PROJECT_NAME} PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}) set_target_properties(${PROJECT_NAME} PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR})
endif()
# --- CLANG-FORMAT TARGETS --- # --- EINA STANDALONE: pack_resources ---
# Executable auxiliar que empaqueta `data/` a `resources.pack` (format AEE1).
# No es compila per defecte (EXCLUDE_FROM_ALL). Build explícit:
# cmake --build build --target pack_resources
# Després executar: ./build/pack_resources data resources.pack
if(NOT EMSCRIPTEN)
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)
# --- Regeneració automàtica de resources.pack ---
# Cada `cmake --build build` torna a empaquetar `data/` si algun fitxer 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_SOURCE_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_SOURCE_DIR}
COMMENT "Empaquetant data/ → resources.pack"
VERBATIM
)
add_custom_target(resource_pack ALL DEPENDS ${RESOURCE_PACK})
add_dependencies(${PROJECT_NAME} resource_pack)
endif()
# --- STATIC ANALYSIS TARGETS ---
find_program(CLANG_TIDY_EXE NAMES clang-tidy)
find_program(CLANG_FORMAT_EXE NAMES clang-format) find_program(CLANG_FORMAT_EXE NAMES clang-format)
find_program(CPPCHECK_EXE NAMES cppcheck)
# Recopilar todos los archivos fuente para formateo (excluir external/) # Recopilar todos los archivos fuente (excluir external/)
file(GLOB_RECURSE ALL_SOURCE_FILES file(GLOB_RECURSE ALL_SOURCE_FILES
"${CMAKE_SOURCE_DIR}/source/*.cpp" "${CMAKE_SOURCE_DIR}/source/*.cpp"
"${CMAKE_SOURCE_DIR}/source/*.hpp" "${CMAKE_SOURCE_DIR}/source/*.hpp"
@@ -175,6 +313,35 @@ file(GLOB_RECURSE ALL_SOURCE_FILES
) )
list(FILTER ALL_SOURCE_FILES EXCLUDE REGEX ".*/external/.*") list(FILTER ALL_SOURCE_FILES EXCLUDE REGEX ".*/external/.*")
set(CLANG_TIDY_SOURCES ${ALL_SOURCE_FILES})
# Para cppcheck, pasar solo .cpp (los headers se procesan transitivamente).
set(CPPCHECK_SOURCES ${ALL_SOURCE_FILES})
list(FILTER CPPCHECK_SOURCES INCLUDE REGEX ".*\\.cpp$")
# Targets de clang-tidy
if(CLANG_TIDY_EXE)
add_custom_target(tidy
COMMAND ${CLANG_TIDY_EXE}
-p ${CMAKE_BINARY_DIR}
${CLANG_TIDY_SOURCES}
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
COMMENT "Running clang-tidy..."
)
add_custom_target(tidy-fix
COMMAND ${CLANG_TIDY_EXE}
-p ${CMAKE_BINARY_DIR}
--fix
${CLANG_TIDY_SOURCES}
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
COMMENT "Running clang-tidy with fixes..."
)
else()
message(STATUS "clang-tidy no encontrado - targets 'tidy' y 'tidy-fix' no disponibles")
endif()
# Targets de clang-format
if(CLANG_FORMAT_EXE) if(CLANG_FORMAT_EXE)
add_custom_target(format add_custom_target(format
COMMAND ${CLANG_FORMAT_EXE} COMMAND ${CLANG_FORMAT_EXE}
@@ -195,3 +362,25 @@ if(CLANG_FORMAT_EXE)
else() else()
message(STATUS "clang-format no encontrado - targets 'format' y 'format-check' no disponibles") message(STATUS "clang-format no encontrado - targets 'format' y 'format-check' no disponibles")
endif() endif()
# Target de cppcheck
if(CPPCHECK_EXE)
add_custom_target(cppcheck
COMMAND ${CPPCHECK_EXE}
--enable=warning,style,performance,portability
--std=c++20
--language=c++
--inline-suppr
--suppress=missingIncludeSystem
--suppress=toomanyconfigs
--suppress=*:*/source/external/*
--suppress=*:*/source/core/rendering/sdl3gpu/spv/*
--quiet
-I ${CMAKE_SOURCE_DIR}/source
${CPPCHECK_SOURCES}
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
COMMENT "Running cppcheck..."
)
else()
message(STATUS "cppcheck no encontrado - target 'cppcheck' no disponible")
endif()

265
Makefile
View File

@@ -4,6 +4,18 @@
DIR_ROOT := $(dir $(abspath $(MAKEFILE_LIST))) DIR_ROOT := $(dir $(abspath $(MAKEFILE_LIST)))
DIR_BIN := $(addsuffix /, $(DIR_ROOT)) DIR_BIN := $(addsuffix /, $(DIR_ROOT))
# ==============================================================================
# TOOLS
# ==============================================================================
SHADER_CMAKE := $(DIR_ROOT)tools/shaders/compile_spirv.cmake
SHADERS_DIR := $(DIR_ROOT)data/shaders
HEADERS_DIR := $(DIR_ROOT)source/core/rendering/sdl3gpu
ifeq ($(OS),Windows_NT)
GLSLC := $(shell where glslc 2>NUL)
else
GLSLC := $(shell command -v glslc 2>/dev/null)
endif
# ============================================================================== # ==============================================================================
# TARGET NAMES # TARGET NAMES
# ============================================================================== # ==============================================================================
@@ -18,9 +30,23 @@ RELEASE_FILE := $(RELEASE_FOLDER)/$(TARGET_NAME)
# VERSION (extracted from defines.hpp) # VERSION (extracted from defines.hpp)
# ============================================================================== # ==============================================================================
ifeq ($(OS),Windows_NT) ifeq ($(OS),Windows_NT)
VERSION := v$(shell powershell -Command "(Select-String -Path 'source/game/defines.hpp' -Pattern 'constexpr const char\* VERSION = \"(.+?)\"').Matches.Groups[1].Value") VERSION := $(shell powershell -Command "(Select-String -Path 'source/game/defines.hpp' -Pattern 'constexpr const char\* VERSION = \"(.+?)\"').Matches.Groups[1].Value")
else else
VERSION := v$(shell grep 'constexpr const char\* VERSION' source/game/defines.hpp | sed -E 's/.*VERSION = "([^"]+)".*/\1/') VERSION := $(shell grep 'constexpr const char\* VERSION' source/game/defines.hpp | sed -E 's/.*VERSION = "([^"]+)".*/\1/')
endif
# ==============================================================================
# GIT HASH (computat al host, passat a CMake via -DGIT_HASH)
# Evita que CMake haja de cridar git des de Docker/emscripten on falla per
# "dubious ownership" del volum muntat.
# ==============================================================================
ifeq ($(OS),Windows_NT)
GIT_HASH := $(shell git rev-parse --short=7 HEAD 2>NUL)
else
GIT_HASH := $(shell git rev-parse --short=7 HEAD 2>/dev/null)
endif
ifeq ($(GIT_HASH),)
GIT_HASH := unknown
endif endif
# ============================================================================== # ==============================================================================
@@ -37,9 +63,13 @@ endif
ifeq ($(OS),Windows_NT) ifeq ($(OS),Windows_NT)
WIN_TARGET_FILE := $(DIR_BIN)$(APP_NAME) WIN_TARGET_FILE := $(DIR_BIN)$(APP_NAME)
WIN_RELEASE_FILE := $(RELEASE_FOLDER)/$(APP_NAME) WIN_RELEASE_FILE := $(RELEASE_FOLDER)/$(APP_NAME)
# Escapa apòstrofs per a PowerShell (duplica ' → ''). Sense això, APP_NAMEs
# com "JailDoctor's Dilemma" trencarien el parsing de -Destination '...'.
WIN_RELEASE_FILE_PS := $(subst ','',$(WIN_RELEASE_FILE))
else else
WIN_TARGET_FILE := $(TARGET_FILE) WIN_TARGET_FILE := $(TARGET_FILE)
WIN_RELEASE_FILE := $(RELEASE_FILE) WIN_RELEASE_FILE := $(RELEASE_FILE)
WIN_RELEASE_FILE_PS := $(WIN_RELEASE_FILE)
endif endif
# ============================================================================== # ==============================================================================
@@ -65,17 +95,42 @@ else
UNAME_S := $(shell uname -s) UNAME_S := $(shell uname -s)
endif endif
# ==============================================================================
# CMAKE GENERATOR (Windows needs explicit MinGW Makefiles generator)
# ==============================================================================
ifeq ($(OS),Windows_NT)
CMAKE_GEN := -G "MinGW Makefiles"
else
CMAKE_GEN :=
endif
# ============================================================================== # ==============================================================================
# COMPILACIÓN CON CMAKE # COMPILACIÓN CON CMAKE
# ============================================================================== # ==============================================================================
all: all:
@cmake -S . -B build -DCMAKE_BUILD_TYPE=Release @cmake $(CMAKE_GEN) -S . -B build -DCMAKE_BUILD_TYPE=Release -DGIT_HASH=$(GIT_HASH)
@cmake --build build @cmake --build build
debug: debug:
@cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug @cmake $(CMAKE_GEN) -S . -B build -DCMAKE_BUILD_TYPE=Debug -DGIT_HASH=$(GIT_HASH)
@cmake --build build @cmake --build build
# ==============================================================================
# REGLAS PARA COMPILACIÓN DE SHADERS (multiplataforma via cmake)
# ==============================================================================
compile_shaders:
ifdef GLSLC
@cmake -D GLSLC=$(GLSLC) -D SHADERS_DIR=$(SHADERS_DIR) -D HEADERS_DIR=$(HEADERS_DIR) -P $(SHADER_CMAKE)
else
@echo "glslc no encontrado - asegurate de que los headers SPIR-V precompilados existen"
endif
# Empaqueta data/ a resources.pack (format AEE1). Build previ de l'eina + execució.
pack:
@cmake $(CMAKE_GEN) -S . -B build -DCMAKE_BUILD_TYPE=Release -DGIT_HASH=$(GIT_HASH)
@cmake --build build --target pack_resources
@./build/pack_resources data resources.pack
# ============================================================================== # ==============================================================================
# RELEASE AUTOMÁTICO (detecta SO) # RELEASE AUTOMÁTICO (detecta SO)
# ============================================================================== # ==============================================================================
@@ -93,12 +148,12 @@ endif
# ============================================================================== # ==============================================================================
# COMPILACIÓN PARA WINDOWS (RELEASE) # COMPILACIÓN PARA WINDOWS (RELEASE)
# ============================================================================== # ==============================================================================
_windows_release: _windows_release: pack
@echo off @echo off
@echo Creando release para Windows - Version: $(VERSION) @echo Creando release para Windows - Version: $(VERSION)
# Compila con cmake # Compila con cmake
@cmake -S . -B build -DCMAKE_BUILD_TYPE=Release @cmake $(CMAKE_GEN) -S . -B build -DCMAKE_BUILD_TYPE=Release -DGIT_HASH=$(GIT_HASH)
@cmake --build build @cmake --build build
# Crea carpeta de distribución y carpeta temporal 'RELEASE_FOLDER' # Crea carpeta de distribución y carpeta temporal 'RELEASE_FOLDER'
@@ -106,13 +161,13 @@ _windows_release:
@powershell -Command "if (Test-Path '$(RELEASE_FOLDER)') {Remove-Item '$(RELEASE_FOLDER)' -Recurse -Force}" @powershell -Command "if (Test-Path '$(RELEASE_FOLDER)') {Remove-Item '$(RELEASE_FOLDER)' -Recurse -Force}"
@powershell -Command "if (-not (Test-Path '$(RELEASE_FOLDER)')) {New-Item '$(RELEASE_FOLDER)' -ItemType Directory}" @powershell -Command "if (-not (Test-Path '$(RELEASE_FOLDER)')) {New-Item '$(RELEASE_FOLDER)' -ItemType Directory}"
# Copia ficheros # Copia ficheros (resources.pack substitueix la carpeta data/)
@powershell -Command "Copy-Item -Path 'data' -Destination '$(RELEASE_FOLDER)' -Recurse" @powershell -Command "Copy-Item 'resources.pack' -Destination '$(RELEASE_FOLDER)'"
@powershell -Command "Copy-Item 'LICENSE' -Destination '$(RELEASE_FOLDER)'" @powershell -Command "Copy-Item 'LICENSE' -Destination '$(RELEASE_FOLDER)'"
@powershell -Command "Copy-Item 'README.md' -Destination '$(RELEASE_FOLDER)'" @powershell -Command "Copy-Item 'README.md' -Destination '$(RELEASE_FOLDER)'"
@powershell -Command "Copy-Item 'gamecontrollerdb.txt' -Destination '$(RELEASE_FOLDER)'" @powershell -Command "Copy-Item 'gamecontrollerdb.txt' -Destination '$(RELEASE_FOLDER)'"
@powershell -Command "Copy-Item 'release\windows\dll\*.dll' -Destination '$(RELEASE_FOLDER)'" @powershell -Command "Copy-Item 'release\windows\dll\*.dll' -Destination '$(RELEASE_FOLDER)'"
@powershell -Command "Copy-Item -Path '$(TARGET_FILE).exe' -Destination '$(WIN_RELEASE_FILE).exe'" @powershell -Command "Copy-Item -Path '$(TARGET_FILE).exe' -Destination '$(WIN_RELEASE_FILE_PS).exe'"
strip -s -R .comment -R .gnu.version "$(WIN_RELEASE_FILE).exe" --strip-unneeded strip -s -R .comment -R .gnu.version "$(WIN_RELEASE_FILE).exe" --strip-unneeded
# Crea el fichero .zip # Crea el fichero .zip
@@ -126,15 +181,31 @@ _windows_release:
# ============================================================================== # ==============================================================================
# COMPILACIÓN PARA MACOS (RELEASE) # COMPILACIÓN PARA MACOS (RELEASE)
# ============================================================================== # ==============================================================================
_macos_release: _macos_release: pack
@echo "Creando release para macOS - Version: $(VERSION)" @echo "Creando release para macOS - Version: $(VERSION)"
# Verificar e instalar create-dmg si es necesario # Verifica dependencias necesarias (create-dmg). Si falta, intenta instalarla
@which create-dmg > /dev/null || (echo "Instalando create-dmg..." && brew install create-dmg) # con brew; si brew tampoco está, indica el comando exacto al usuario.
@command -v create-dmg >/dev/null 2>&1 || { \
# Compila la versión para procesadores Intel con cmake echo ""; \
@cmake -S . -B build/intel -DCMAKE_BUILD_TYPE=Release -DCMAKE_OSX_ARCHITECTURES=x86_64 -DCMAKE_OSX_DEPLOYMENT_TARGET=10.15 -DMACOS_BUNDLE=ON echo "============================================"; \
@cmake --build build/intel echo " Falta la dependencia: create-dmg"; \
echo "============================================"; \
if command -v brew >/dev/null 2>&1; then \
echo " Instalando con: brew install create-dmg"; \
brew install create-dmg || { \
echo ""; \
echo " ERROR: 'brew install create-dmg' ha fallado."; \
echo " Ejecuta el comando manualmente y vuelve a probar."; \
exit 1; \
}; \
else \
echo " Homebrew no está instalado."; \
echo " Instálalo desde https://brew.sh y luego ejecuta:"; \
echo " brew install create-dmg"; \
exit 1; \
fi; \
}
# Elimina datos de compilaciones anteriores # Elimina datos de compilaciones anteriores
$(RMDIR) "$(RELEASE_FOLDER)" $(RMDIR) "$(RELEASE_FOLDER)"
@@ -148,8 +219,8 @@ _macos_release:
$(MKDIR) "$(RELEASE_FOLDER)/$(APP_NAME).app/Contents/MacOS" $(MKDIR) "$(RELEASE_FOLDER)/$(APP_NAME).app/Contents/MacOS"
$(MKDIR) "$(RELEASE_FOLDER)/$(APP_NAME).app/Contents/Resources" $(MKDIR) "$(RELEASE_FOLDER)/$(APP_NAME).app/Contents/Resources"
# Copia carpetas y ficheros # Copia carpetas y ficheros (resources.pack substitueix la carpeta data/)
cp -R data "$(RELEASE_FOLDER)/$(APP_NAME).app/Contents/Resources" cp resources.pack "$(RELEASE_FOLDER)/$(APP_NAME).app/Contents/Resources"
cp gamecontrollerdb.txt "$(RELEASE_FOLDER)/$(APP_NAME).app/Contents/Resources" cp gamecontrollerdb.txt "$(RELEASE_FOLDER)/$(APP_NAME).app/Contents/Resources"
cp -R release/macos/frameworks/SDL3.xcframework/macos-arm64_x86_64/SDL3.framework "$(RELEASE_FOLDER)/$(APP_NAME).app/Contents/Frameworks" cp -R release/macos/frameworks/SDL3.xcframework/macos-arm64_x86_64/SDL3.framework "$(RELEASE_FOLDER)/$(APP_NAME).app/Contents/Frameworks"
cp release/icons/*.icns "$(RELEASE_FOLDER)/$(APP_NAME).app/Contents/Resources" cp release/icons/*.icns "$(RELEASE_FOLDER)/$(APP_NAME).app/Contents/Resources"
@@ -163,14 +234,20 @@ _macos_release:
sed -i '' '/<key>CFBundleShortVersionString<\/key>/{n;s|<string>.*</string>|<string>'"$$RAW_VERSION"'</string>|;}' "$(RELEASE_FOLDER)/$(APP_NAME).app/Contents/Info.plist"; \ sed -i '' '/<key>CFBundleShortVersionString<\/key>/{n;s|<string>.*</string>|<string>'"$$RAW_VERSION"'</string>|;}' "$(RELEASE_FOLDER)/$(APP_NAME).app/Contents/Info.plist"; \
sed -i '' '/<key>CFBundleVersion<\/key>/{n;s|<string>.*</string>|<string>'"$$RAW_VERSION"'</string>|;}' "$(RELEASE_FOLDER)/$(APP_NAME).app/Contents/Info.plist" sed -i '' '/<key>CFBundleVersion<\/key>/{n;s|<string>.*</string>|<string>'"$$RAW_VERSION"'</string>|;}' "$(RELEASE_FOLDER)/$(APP_NAME).app/Contents/Info.plist"
# Copia el ejecutable Intel al bundle # Compila y empaqueta la versión Intel (best-effort: si falla, se omite el
cp "$(TARGET_FILE)" "$(RELEASE_FOLDER)/$(APP_NAME).app/Contents/MacOS/$(TARGET_NAME)" # DMG Intel y continúa con la build de Apple Silicon).
@echo ""
# Firma la aplicación @echo "============================================"
codesign --deep --force --sign - --timestamp=none "$(RELEASE_FOLDER)/$(APP_NAME).app" @echo " Compilando version Intel (x86_64)"
@echo "============================================"
# Empaqueta el .dmg de la versión Intel con create-dmg @if cmake -S . -B build/intel -DCMAKE_BUILD_TYPE=Release \
@echo "Creando DMG Intel con iconos de 96x96..." -DCMAKE_OSX_ARCHITECTURES=x86_64 \
-DCMAKE_OSX_DEPLOYMENT_TARGET=10.15 \
-DMACOS_BUNDLE=ON -DGIT_HASH=$(GIT_HASH) \
&& cmake --build build/intel; then \
cp "$(TARGET_FILE)" "$(RELEASE_FOLDER)/$(APP_NAME).app/Contents/MacOS/$(TARGET_NAME)"; \
codesign --deep --force --sign - --timestamp=none "$(RELEASE_FOLDER)/$(APP_NAME).app"; \
echo "Creando DMG Intel con iconos de 96x96..."; \
create-dmg \ create-dmg \
--volname "$(APP_NAME)" \ --volname "$(APP_NAME)" \
--window-pos 200 120 \ --window-pos 200 120 \
@@ -183,11 +260,24 @@ _macos_release:
--app-drop-link 115 102 \ --app-drop-link 115 102 \
--hide-extension "$(APP_NAME).app" \ --hide-extension "$(APP_NAME).app" \
"$(MACOS_INTEL_RELEASE)" \ "$(MACOS_INTEL_RELEASE)" \
"$(RELEASE_FOLDER)" || true "$(RELEASE_FOLDER)" || true; \
@echo "Release Intel creado: $(MACOS_INTEL_RELEASE)" echo "Release Intel creado: $(MACOS_INTEL_RELEASE)"; \
else \
echo ""; \
echo "============================================"; \
echo " WARNING: la build Intel ha fallado."; \
echo " Se omite el DMG Intel y se continúa con"; \
echo " la build de Apple Silicon."; \
echo "============================================"; \
echo ""; \
fi
# Compila la versión para procesadores Apple Silicon con cmake # Compila la versión para procesadores Apple Silicon con cmake
@cmake -S . -B build/arm -DCMAKE_BUILD_TYPE=Release -DCMAKE_OSX_ARCHITECTURES=arm64 -DCMAKE_OSX_DEPLOYMENT_TARGET=11.0 -DMACOS_BUNDLE=ON @echo ""
@echo "============================================"
@echo " Compilando version Apple Silicon (arm64)"
@echo "============================================"
@cmake -S . -B build/arm -DCMAKE_BUILD_TYPE=Release -DCMAKE_OSX_ARCHITECTURES=arm64 -DCMAKE_OSX_DEPLOYMENT_TARGET=11.0 -DMACOS_BUNDLE=ON -DGIT_HASH=$(GIT_HASH)
@cmake --build build/arm @cmake --build build/arm
cp "$(TARGET_FILE)" "$(RELEASE_FOLDER)/$(APP_NAME).app/Contents/MacOS/$(TARGET_NAME)" cp "$(TARGET_FILE)" "$(RELEASE_FOLDER)/$(APP_NAME).app/Contents/MacOS/$(TARGET_NAME)"
@@ -217,22 +307,64 @@ _macos_release:
$(RMDIR) build/arm $(RMDIR) build/arm
$(RMFILE) "$(DIST_DIR)"/rw.* $(RMFILE) "$(DIST_DIR)"/rw.*
# ==============================================================================
# COMPILACIÓN PARA WEBASSEMBLY (requiere Docker amb emscripten/emsdk)
# ==============================================================================
# Genera aee.{html,js,wasm,data} a dist/wasm/. Es pot provar servint amb un
# servidor HTTP local (els navegadors no carreguen `file://` WASM):
# cd dist/wasm && python3 -m http.server 8000
# # després obrir http://localhost:8000/aee.html
wasm:
@echo "Creando release para WebAssembly - Version: $(VERSION)"
docker run --rm \
--user $(shell id -u):$(shell id -g) \
-v $(DIR_ROOT):/src \
-w /src \
emscripten/emsdk:latest \
bash -c "emcmake cmake -S . -B build/wasm -DCMAKE_BUILD_TYPE=Release -DGIT_HASH=$(GIT_HASH) && cmake --build build/wasm"
@$(MKDIR) "$(DIST_DIR)/wasm"
@cp build/wasm/$(TARGET_NAME).html $(DIST_DIR)/wasm/
@cp build/wasm/$(TARGET_NAME).js $(DIST_DIR)/wasm/
@cp build/wasm/$(TARGET_NAME).wasm $(DIST_DIR)/wasm/
@cp build/wasm/$(TARGET_NAME).data $(DIST_DIR)/wasm/
@echo "Output: $(DIST_DIR)/wasm/$(TARGET_NAME).html"
scp $(DIST_DIR)/wasm/$(TARGET_NAME).js $(DIST_DIR)/wasm/$(TARGET_NAME).wasm $(DIST_DIR)/wasm/$(TARGET_NAME).data \
maverick:/home/sergio/gitea/web_jailgames/static/games/aee/wasm/
ssh maverick 'cd /home/sergio/gitea/web_jailgames && ./deploy.sh'
@echo "Deployed to maverick"
# Versió Debug del build wasm: build local sense deploy. Sortida a dist/wasm_debug/.
wasm_debug:
@echo "Compilando WebAssembly Debug - Version: $(VERSION)"
docker run --rm \
--user $(shell id -u):$(shell id -g) \
-v $(DIR_ROOT):/src \
-w /src \
emscripten/emsdk:latest \
bash -c "emcmake cmake -S . -B build/wasm_debug -DCMAKE_BUILD_TYPE=Debug -DGIT_HASH=$(GIT_HASH) && cmake --build build/wasm_debug"
@$(MKDIR) "$(DIST_DIR)/wasm_debug"
@cp build/wasm_debug/$(TARGET_NAME).html $(DIST_DIR)/wasm_debug/
@cp build/wasm_debug/$(TARGET_NAME).js $(DIST_DIR)/wasm_debug/
@cp build/wasm_debug/$(TARGET_NAME).wasm $(DIST_DIR)/wasm_debug/
@cp build/wasm_debug/$(TARGET_NAME).data $(DIST_DIR)/wasm_debug/
@echo "Output: $(DIST_DIR)/wasm_debug/$(TARGET_NAME).html"
# ============================================================================== # ==============================================================================
# COMPILACIÓN PARA LINUX (RELEASE) # COMPILACIÓN PARA LINUX (RELEASE)
# ============================================================================== # ==============================================================================
_linux_release: _linux_release: pack
@echo "Creando release para Linux - Version: $(VERSION)" @echo "Creando release para Linux - Version: $(VERSION)"
# Compila con cmake # Compila con cmake
@cmake -S . -B build -DCMAKE_BUILD_TYPE=Release @cmake $(CMAKE_GEN) -S . -B build -DCMAKE_BUILD_TYPE=Release -DGIT_HASH=$(GIT_HASH)
@cmake --build build @cmake --build build
# Elimina carpeta temporal previa y la recrea (crea dist/ si no existe) # Elimina carpeta temporal previa y la recrea (crea dist/ si no existe)
$(RMDIR) "$(RELEASE_FOLDER)" $(RMDIR) "$(RELEASE_FOLDER)"
$(MKDIR) "$(RELEASE_FOLDER)" $(MKDIR) "$(RELEASE_FOLDER)"
# Copia ficheros # Copia ficheros (resources.pack substitueix la carpeta data/)
cp -r data "$(RELEASE_FOLDER)" cp resources.pack "$(RELEASE_FOLDER)"
cp LICENSE "$(RELEASE_FOLDER)" cp LICENSE "$(RELEASE_FOLDER)"
cp README.md "$(RELEASE_FOLDER)" cp README.md "$(RELEASE_FOLDER)"
cp gamecontrollerdb.txt "$(RELEASE_FOLDER)" cp gamecontrollerdb.txt "$(RELEASE_FOLDER)"
@@ -247,4 +379,69 @@ _linux_release:
# Elimina la carpeta temporal # Elimina la carpeta temporal
$(RMDIR) "$(RELEASE_FOLDER)" $(RMDIR) "$(RELEASE_FOLDER)"
.PHONY: all debug release _windows_release _linux_release _macos_release # ==============================================================================
# ==============================================================================
# CODE QUALITY (delegados a cmake)
# ==============================================================================
format:
@cmake $(CMAKE_GEN) -S . -B build -DCMAKE_BUILD_TYPE=Release -DGIT_HASH=$(GIT_HASH)
@cmake --build build --target format
format-check:
@cmake $(CMAKE_GEN) -S . -B build -DCMAKE_BUILD_TYPE=Release -DGIT_HASH=$(GIT_HASH)
@cmake --build build --target format-check
tidy:
@cmake $(CMAKE_GEN) -S . -B build -DCMAKE_BUILD_TYPE=Release -DGIT_HASH=$(GIT_HASH)
@cmake --build build --target tidy
tidy-fix:
@cmake $(CMAKE_GEN) -S . -B build -DCMAKE_BUILD_TYPE=Release -DGIT_HASH=$(GIT_HASH)
@cmake --build build --target tidy-fix
cppcheck:
@cmake $(CMAKE_GEN) -S . -B build -DCMAKE_BUILD_TYPE=Release -DGIT_HASH=$(GIT_HASH)
@cmake --build build --target cppcheck
# DESCÀRREGA DE GAMECONTROLLERDB
# ==============================================================================
controllerdb:
@echo "Descarregant gamecontrollerdb.txt..."
curl -fsSL https://raw.githubusercontent.com/mdqinc/SDL_GameControllerDB/master/gamecontrollerdb.txt \
-o gamecontrollerdb.txt
@echo "gamecontrollerdb.txt actualitzat"
# ==============================================================================
# AJUDA
# ==============================================================================
help:
@echo "Makefile per a Aventures en Egipte"
@echo "Comandes disponibles:"
@echo ""
@echo " Compilacio:"
@echo " make - Compilar amb cmake (Release)"
@echo " make debug - Compilar amb cmake (Debug)"
@echo ""
@echo " Release:"
@echo " make release - Crear release (detecta SO automaticament)"
@echo " make wasm - Build WebAssembly (requereix Docker) + deploy a maverick"
@echo " make wasm_debug - Build WebAssembly Debug local (sense deploy)"
@echo ""
@echo " Eines:"
@echo " make compile_shaders - Compilar shaders SPIR-V"
@echo " make pack - Empaquetar data/ a resources.pack (format AEE1)"
@echo " make controllerdb - Actualitzar gamecontrollerdb.txt des de SDL_GameControllerDB"
@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 help - Mostrar esta ajuda"
@echo ""
@echo " Versio actual: $(VERSION) ($(GIT_HASH))"
.PHONY: all debug pack release wasm wasm_debug _windows_release _linux_release _macos_release compile_shaders controllerdb format format-check tidy tidy-fix cppcheck help

52
data/config/assets.yaml Normal file
View File

@@ -0,0 +1,52 @@
# Aventures En Egipte - Asset Configuration
# Loaded at boot by Resource::List, decoded incrementally by Resource::Cache.
# Paths are relative to the resource pack root (i.e. relative to ./data/ in dev).
assets:
# FONTS - bitmap font for the overlay (8bithud)
fonts:
BITMAP:
- fonts/8bithud.gif
FONT:
- fonts/8bithud.fnt
# LOCALE - UI strings
locale:
DATA:
- locale/ca.yaml
# INPUT - UI key bindings defaults
input:
DATA:
- input/keys.yaml
# MUSIC - 8 OGG tracks
music:
MUSIC:
- music/banner.ogg
- music/final.ogg
- music/menu.ogg
- music/mort.ogg
- music/piramide_1_4_5.ogg
- music/piramide_2.ogg
- music/piramide_3.ogg
- music/secreta.ogg
# GFX - 14 GIFs (sprites + cinematic backgrounds)
gfx:
BITMAP:
- gfx/ffase.gif
- gfx/final.gif
- gfx/finals.gif
- gfx/frames.gif
- gfx/frames2.gif
- gfx/gameover.gif
- gfx/intro.gif
- gfx/intro2.gif
- gfx/intro3.gif
- gfx/logo.gif
- gfx/logo_new.gif
- gfx/menu.gif
- gfx/menu2.gif
- gfx/tomba1.gif
- gfx/tomba2.gif

View File

@@ -1,234 +0,0 @@
/*
crt-pi - A Raspberry Pi friendly CRT shader.
Copyright (C) 2015-2016 davej
This program is free software; you can redistribute it and/or modify it
under the terms of the GNU General Public License as published by the Free
Software Foundation; either version 2 of the License, or (at your option)
any later version.
Notes:
This shader is designed to work well on Raspberry Pi GPUs (i.e. 1080P @ 60Hz on a game with a 4:3 aspect ratio). It pushes the Pi's GPU hard and enabling some features will slow it down so that it is no longer able to match 1080P @ 60Hz. You will need to overclock your Pi to the fastest setting in raspi-config to get the best results from this shader: 'Pi2' for Pi2 and 'Turbo' for original Pi and Pi Zero. Note: Pi2s are slower at running the shader than other Pis, this seems to be down to Pi2s lower maximum memory speed. Pi2s don't quite manage 1080P @ 60Hz - they drop about 1 in 1000 frames. You probably won't notice this, but if you do, try enabling FAKE_GAMMA.
SCANLINES enables scanlines. You'll almost certainly want to use it with MULTISAMPLE to reduce moire effects. SCANLINE_WEIGHT defines how wide scanlines are (it is an inverse value so a higher number = thinner lines). SCANLINE_GAP_BRIGHTNESS defines how dark the gaps between the scan lines are. Darker gaps between scan lines make moire effects more likely.
GAMMA enables gamma correction using the values in INPUT_GAMMA and OUTPUT_GAMMA. FAKE_GAMMA causes it to ignore the values in INPUT_GAMMA and OUTPUT_GAMMA and approximate gamma correction in a way which is faster than true gamma whilst still looking better than having none. You must have GAMMA defined to enable FAKE_GAMMA.
CURVATURE distorts the screen by CURVATURE_X and CURVATURE_Y. Curvature slows things down a lot.
By default the shader uses linear blending horizontally. If you find this too blury, enable SHARPER.
BLOOM_FACTOR controls the increase in width for bright scanlines.
MASK_TYPE defines what, if any, shadow mask to use. MASK_BRIGHTNESS defines how much the mask type darkens the screen.
*/
#pragma parameter CURVATURE_X "Screen curvature - horizontal" 0.10 0.0 1.0 0.01
#pragma parameter CURVATURE_Y "Screen curvature - vertical" 0.15 0.0 1.0 0.01
#pragma parameter MASK_BRIGHTNESS "Mask brightness" 0.70 0.0 1.0 0.01
#pragma parameter SCANLINE_WEIGHT "Scanline weight" 6.0 0.0 15.0 0.1
#pragma parameter SCANLINE_GAP_BRIGHTNESS "Scanline gap brightness" 0.12 0.0 1.0 0.01
#pragma parameter BLOOM_FACTOR "Bloom factor" 1.5 0.0 5.0 0.01
#pragma parameter INPUT_GAMMA "Input gamma" 2.4 0.0 5.0 0.01
#pragma parameter OUTPUT_GAMMA "Output gamma" 2.2 0.0 5.0 0.01
// Haven't put these as parameters as it would slow the code down.
#define SCANLINES
#define MULTISAMPLE
#define GAMMA
//#define FAKE_GAMMA
#define CURVATURE
//#define SHARPER
// MASK_TYPE: 0 = none, 1 = green/magenta, 2 = trinitron(ish)
#define MASK_TYPE 1
#ifdef GL_ES
#define COMPAT_PRECISION mediump
precision mediump float;
#else
#define COMPAT_PRECISION
#endif
#ifdef PARAMETER_UNIFORM
uniform COMPAT_PRECISION float CURVATURE_X;
uniform COMPAT_PRECISION float CURVATURE_Y;
uniform COMPAT_PRECISION float MASK_BRIGHTNESS;
uniform COMPAT_PRECISION float SCANLINE_WEIGHT;
uniform COMPAT_PRECISION float SCANLINE_GAP_BRIGHTNESS;
uniform COMPAT_PRECISION float BLOOM_FACTOR;
uniform COMPAT_PRECISION float INPUT_GAMMA;
uniform COMPAT_PRECISION float OUTPUT_GAMMA;
#else
#define CURVATURE_X 0.25
#define CURVATURE_Y 0.45
#define MASK_BRIGHTNESS 0.70
#define SCANLINE_WEIGHT 6.0
#define SCANLINE_GAP_BRIGHTNESS 0.12
#define BLOOM_FACTOR 1.5
#define INPUT_GAMMA 2.4
#define OUTPUT_GAMMA 2.2
#endif
/* COMPATIBILITY
- GLSL compilers
*/
//uniform vec2 TextureSize;
#if defined(CURVATURE)
varying vec2 screenScale;
#endif
varying vec2 TEX0;
varying float filterWidth;
#if defined(VERTEX)
//uniform mat4 MVPMatrix;
//attribute vec4 VertexCoord;
//attribute vec2 TexCoord;
//uniform vec2 InputSize;
//uniform vec2 OutputSize;
void main()
{
#if defined(CURVATURE)
screenScale = vec2(1.0, 1.0); //TextureSize / InputSize;
#endif
filterWidth = (768.0 / 240.0) / 3.0;
TEX0 = vec2(gl_MultiTexCoord0.x, 1.0-gl_MultiTexCoord0.y)*1.0001;
gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex;
}
#elif defined(FRAGMENT)
uniform sampler2D Texture;
#if defined(CURVATURE)
vec2 Distort(vec2 coord)
{
vec2 CURVATURE_DISTORTION = vec2(CURVATURE_X, CURVATURE_Y);
// Barrel distortion shrinks the display area a bit, this will allow us to counteract that.
vec2 barrelScale = 1.0 - (0.23 * CURVATURE_DISTORTION);
coord *= screenScale;
coord -= vec2(0.5);
float rsq = coord.x * coord.x + coord.y * coord.y;
coord += coord * (CURVATURE_DISTORTION * rsq);
coord *= barrelScale;
if (abs(coord.x) >= 0.5 || abs(coord.y) >= 0.5)
coord = vec2(-1.0); // If out of bounds, return an invalid value.
else
{
coord += vec2(0.5);
coord /= screenScale;
}
return coord;
}
#endif
float CalcScanLineWeight(float dist)
{
return max(1.0-dist*dist*SCANLINE_WEIGHT, SCANLINE_GAP_BRIGHTNESS);
}
float CalcScanLine(float dy)
{
float scanLineWeight = CalcScanLineWeight(dy);
#if defined(MULTISAMPLE)
scanLineWeight += CalcScanLineWeight(dy-filterWidth);
scanLineWeight += CalcScanLineWeight(dy+filterWidth);
scanLineWeight *= 0.3333333;
#endif
return scanLineWeight;
}
void main()
{
vec2 TextureSize = vec2(320.0, 240.0);
#if defined(CURVATURE)
vec2 texcoord = Distort(TEX0);
if (texcoord.x < 0.0)
gl_FragColor = vec4(0.0);
else
#else
vec2 texcoord = TEX0;
#endif
{
vec2 texcoordInPixels = texcoord * TextureSize;
#if defined(SHARPER)
vec2 tempCoord = floor(texcoordInPixels) + 0.5;
vec2 coord = tempCoord / TextureSize;
vec2 deltas = texcoordInPixels - tempCoord;
float scanLineWeight = CalcScanLine(deltas.y);
vec2 signs = sign(deltas);
deltas.x *= 2.0;
deltas = deltas * deltas;
deltas.y = deltas.y * deltas.y;
deltas.x *= 0.5;
deltas.y *= 8.0;
deltas /= TextureSize;
deltas *= signs;
vec2 tc = coord + deltas;
#else
float tempY = floor(texcoordInPixels.y) + 0.5;
float yCoord = tempY / TextureSize.y;
float dy = texcoordInPixels.y - tempY;
float scanLineWeight = CalcScanLine(dy);
float signY = sign(dy);
dy = dy * dy;
dy = dy * dy;
dy *= 8.0;
dy /= TextureSize.y;
dy *= signY;
vec2 tc = vec2(texcoord.x, yCoord + dy);
#endif
vec3 colour = texture2D(Texture, tc).rgb;
#if defined(SCANLINES)
#if defined(GAMMA)
#if defined(FAKE_GAMMA)
colour = colour * colour;
#else
colour = pow(colour, vec3(INPUT_GAMMA));
#endif
#endif
scanLineWeight *= BLOOM_FACTOR;
colour *= scanLineWeight;
#if defined(GAMMA)
#if defined(FAKE_GAMMA)
colour = sqrt(colour);
#else
colour = pow(colour, vec3(1.0/OUTPUT_GAMMA));
#endif
#endif
#endif
#if MASK_TYPE == 0
gl_FragColor = vec4(colour, 1.0);
#else
#if MASK_TYPE == 1
float whichMask = fract((gl_FragCoord.x*1.0001) * 0.5);
vec3 mask;
if (whichMask < 0.5)
mask = vec3(MASK_BRIGHTNESS, 1.0, MASK_BRIGHTNESS);
else
mask = vec3(1.0, MASK_BRIGHTNESS, 1.0);
#elif MASK_TYPE == 2
float whichMask = fract((gl_FragCoord.x*1.0001) * 0.3333333);
vec3 mask = vec3(MASK_BRIGHTNESS, MASK_BRIGHTNESS, MASK_BRIGHTNESS);
if (whichMask < 0.3333333)
mask.x = 1.0;
else if (whichMask < 0.6666666)
mask.y = 1.0;
else
mask.z = 1.0;
#endif
gl_FragColor = vec4(colour * mask, 1.0);
#endif
}
}
#endif

View File

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

50
data/input/keys.yaml Normal file
View File

@@ -0,0 +1,50 @@
# Aventures En Egipte — Configuració de tecles d'UI
#
# Font única de veritat per a les tecles de funció / sistema.
# Les tecles de moviment del jugador viuen separades a config.yaml (secció `controls:`).
#
# Si l'usuari remapeja alguna tecla des del menú de servei, la diferència respecte
# aquests valors per defecte es persistix a ~/.config/jailgames/aee/keys.yaml.
#
# Camps:
# id - Identificador usat des del codi via KeyConfig::scancode("id")
# code - Nom SDL del scancode (per SDL_GetScancodeFromName), p.ex. "F1", "Escape"
# desc - Descripció curta (per a HELP / overlays futurs)
keys:
- id: dec_zoom
code: "F1"
desc: "Redueix el zoom de la finestra"
- id: inc_zoom
code: "F2"
desc: "Augmenta el zoom de la finestra"
- id: fullscreen
code: "F3"
desc: "Pantalla completa"
- id: toggle_shader
code: "F4"
desc: "Activa/desactiva shaders"
- id: toggle_aspect_ratio
code: "F5"
desc: "Aspecte 4:3 / pixels quadrats"
- id: toggle_supersampling
code: "F6"
desc: "Activa/desactiva supersampling"
- id: next_shader
code: "F7"
desc: "Tipus de shader (PostFX / CRT-Pi)"
- id: next_shader_preset
code: "F8"
desc: "Pròxim preset del shader"
- id: cycle_texture_filter
code: "F9"
desc: "Filtre de textura (nearest / linear)"
- id: toggle_render_info
code: "F10"
desc: "Mostra info de renderitzat"
- id: pause_toggle
code: "F11"
desc: "Pausa el joc"
- id: menu_toggle
code: "F12"
desc: "Menú de servei"

View File

@@ -4,77 +4,89 @@
menu: menu:
titles: titles:
root: "OPCIONS" root: "Opcions"
video: "VIDEO" video: "Vídeo"
audio: "AUDIO" audio: "Àudio"
controls: "CONTROLS" controls: "Controls"
game: "JOC" game: "Joc"
system: "Sistema"
items: items:
video: "VIDEO" video: "Vídeo"
audio: "AUDIO" audio: "Àudio"
controls: "CONTROLS" controls: "Controls"
game: "JOC" game: "Joc"
use_new_logo: "LOGO NOU" system: "Sistema"
show_title_credits: "CREDITS DEL PORT" restart: "Reinicia"
zoom: "ZOOM" exit_game: "Eixir del joc"
screen: "PANTALLA" use_new_logo: "Logo nou"
shader: "SHADER" show_title_credits: "Crèdits del port"
aspect_4_3: "ASPECTE 4:3" show_preload: "Barra de precàrrega"
supersampling: "SUPERSAMPLING" zoom: "Zoom"
vsync: "VSYNC" screen: "Pantalla"
integer_scale: "ESCALA ENTERA" shader: "Shader"
shader_type: "TIPUS SHADER" aspect_4_3: "Aspecte 4:3"
preset: "PRESET" supersampling: "Supersampling"
stretch_filter: "FILTRE 4:3" vsync: "Vsync"
render_info: "RENDER INFO" scaling_mode: "Escala"
uptime: "TEMPS DE JOC" shader_type: "Tipus shader"
master_enable: "AUDIO" preset: "Preset"
master_volume: "MASTER" texture_filter: "Filtre textura"
music: "MUSICA" render_info: "Render info"
music_volume: "VOL MUSICA" uptime: "Temps de joc"
sounds: "SONS" internal_resolution: "Resolució interna"
sounds_volume: "VOL SONS" master_enable: "Àudio"
move_up: "MOU AMUNT" master_volume: "Màster"
move_down: "MOU AVALL" music: "Música"
move_left: "MOU ESQUERRA" music_volume: "Vol música"
move_right: "MOU DRETA" sounds: "Sons"
menu_key: "TECLA MENU" sounds_volume: "Vol sons"
move_up: "Mou amunt"
move_down: "Mou avall"
move_left: "Mou esquerra"
move_right: "Mou dreta"
menu_key: "Tecla menú"
values: values:
"yes": "SI" "yes": "Sí"
"no": "NO" "no": "No"
"on": "ON" "on": "On"
"off": "OFF" "off": "Off"
fullscreen: "COMPLETA" fullscreen: "Completa"
windowed: "FINESTRA" windowed: "Finestra"
linear: "LINEAR" linear: "Linear"
nearest: "NEAREST" nearest: "Nearest"
top: "TOP" top: "Top"
bottom: "BOTTOM" bottom: "Bottom"
press_key: "<PREM TECLA>" press_key: "<Prem tecla>"
empty: "(BUIT)" empty: "(Buit)"
unknown: "---" unknown: "---"
scaling_disabled: "Sense escala"
scaling_stretch: "Estirada"
scaling_letterbox: "Letterbox"
scaling_overscan: "Overscan"
scaling_integer: "Entera"
window: window:
title: "© 2000 Aventures en Egipte — JailDesigner" title: "© 2000 Aventures en Egipte — JailDesigner"
notifications: notifications:
exit_double_esc: "TORNA A PULSAR ESC PER EIXIR" exit_double_esc: "Torna a pulsar ESC per a eixir"
zoom_fmt: "ZOOM %dX" zoom_fmt: "Zoom %dX"
fullscreen: "PANTALLA COMPLETA" fullscreen: "Pantalla completa"
windowed: "FINESTRA" windowed: "Finestra"
shader_on: "SHADER ON" shader_on: "Shader on"
shader_off: "SHADER OFF" shader_off: "Shader off"
aspect_43: "4:3 CRT" aspect_43: "4:3 CRT"
aspect_square: "PIXELS QUADRATS" aspect_square: "Píxels quadrats"
ss_on: "SUPERSAMPLING ON" ss_on: "Supersampling on"
ss_off: "SUPERSAMPLING OFF" ss_off: "Supersampling off"
preset_fmt: "PRESET: %s" preset_fmt: "Preset: %s"
filter_linear: "FILTRE: LINEAR" filter_linear: "Filtre: linear"
filter_nearest: "FILTRE: NEAREST" filter_nearest: "Filtre: nearest"
pause: "PAUSA" pause: "Pausa"
resume: "REPRES" gamepad_connected: "connectat"
gamepad_disconnected: "desconnectat"
credits: credits:
port_role: "Conversio a C++ i SDL3" port_role: "Conversio a C++ i SDL3"

View File

View File

@@ -0,0 +1,463 @@
# Reescritura de cinemáticas: capa `scenes::` + migración escena a escena
## Current Status (actualitzat 2026-04-16)
**Steps completats** — capa `scenes::` estable i 7 de 9 escenes migrades:
-**Step 0** — Infraestructura: `Scene`, `Timeline`, `SpriteMover`, `FrameAnimator`, `PaletteFade`, `SurfaceHandle`, `SceneRegistry`, `scene_utils`, dispatch al `gameFiberEntry`.
-**Step 1**`MortScene` (state 100). Pantalla game over + fade-in/out + música "00000001.ogg" → "00000003.ogg".
-**Step 2**`BannerScene` (states 2..5). Banner pre-piràmide amb les 4 variants consolidades a `(idx%2)*160, (idx/2)*75`.
-**Step 3**`MenuScene` (state 0). Primera ús real de `FrameAnimator` (camell 8×160ms). Scrollers manuals amb acumulador ms per palmeres/horitzó. Parpalleig "polsa tecla" time-based.
-**Step 4**`IntroNewLogoScene` (state 255, condicional a `use_new_logo`). Revelat lletra a lletra + cicle de paleta 256 passos. **Delega temporalment a `ModuleSequence::doIntroSprites()`** via `SurfaceHandle::release()` perquè el legacy allibera `gfx` internament. La delegació desapareixerà al Step 9.
-**Step 5**`SlidesScene` (states 1 i 7). Wipe suau amb `Easing::outCubic` (el "rasca" del vell s'ha evaporat). Redirect `6→7` replicat al `gameFiberEntry` abans del `tryCreate` perquè el flux "no tens prou diners" caiga a slides de fracàs.
-**Step 6**`CreditsScene` (state 8). Scroll vertical + parallax condicional si `diamants == 16`. Música heretable (només arranca si no en sona cap ja). Escriu `trick.ini` al final.
-**Step 7**`SecretaScene` (state 6). 11 fases amb swap de `tomba1.gif→tomba2.gif` via `SurfaceHandle::reset()` i efecte "red pulse" sobre els índexs 254/253 de la paleta. Primera ús d'`InitialFadeOut` (fade-out sobre la paleta prèvia abans de muntar la nova).
**Steps pendents** — ataquen el cor de la intro:
- 📋 **Step 8**`IntroScene` (state 255 quan `use_new_logo == false`). 11 passos lineals del wordmark "JAILGAMES" llegat + cicle de paleta. Delegaria a `doIntroSprites` legacy igual que `IntroNewLogoScene`. Estimació: ~150 línies. Complexitat Media-Alta, però lineal.
- 📋 **Step 9**`IntroSpritesScene`. **El hueso**. `switch (rand() % 3)` amb 3 variants completament diferents (~9001100 frames cada una), 68 loops anidats per variant, frames subsamplejats amb màscares diferents. Mateix arxiu `gfx` que la intro que la crida. Si l'API escala mal, s'acceptarà un `tick()` manual sense Timeline. En migrar aquest step s'elimina la delegació temporal `IntroNewLogoScene → doIntroSprites` i `doIntroSprites` pot passar de `public` a privat/eliminat. Complexitat Alta.
- 📋 **Step 10** — Neteja final. `ModuleSequence::doIntro()` legacy també desapareix quan `IntroScene` + `IntroSpritesScene` estan fetes. `wait_frame_or_skip()` helper s'elimina. `ModuleSequence::Go()` queda reduït a ~5 línies o desapareix del tot si es pot treure del `gameFiberEntry`. Pot ser també aquí on s'elimine el `fiber` per fi quan `ModuleGame` siga tick-based, però això és un altre capítol.
**Configuració per a proves ràpides** — afegits al `Options::game` (persistents a `config.yaml`):
- `piramide_inicial` (ja existia) — state d'entrada. Valors útils: `255` = intro normal, `0` = menú, `5` = banner piràmide 5, `6` = SecretaScene, `8` = Credits, `100` = Mort.
- `habitacio_inicial` (ja existia) — sala d'entrada dins la piràmide (1..5).
- `vides` (ja existia).
- `diamants_inicial` — per al final "bo" dels crèdits amb parallax + cotxe, posar a `16`.
- `diners_inicial` — necessari posar `200` per entrar a `SecretaScene` sense el redirect a slides-fracàs (si entres directament en state 6 o hi arribes des del gameplay).
- `show_title_credits` (ja existia) — desactivar-ho accelera els tests.
**Bugs notables resolts al llarg del camí** (mantenir present — poden reaparèixer si es toca codi similar):
1. `JI_Update()` no es cridava dins del mini-while del fiber → `JI_AnyKey()` no es refrescava → les escenes ignoraven les tecles de skip. Fix a [director.cpp:gameFiberEntry](source/core/system/director.cpp) al Step 3.
2. `IntroNewLogoScene::~` doble-free de `gfx_` perquè `doIntroSprites` sempre allibera el `gfx` que rep (tant al final normal com als paths de skip). Fix: `SurfaceHandle::release()` abans de delegar. Step 4.
3. `IntroNewLogoScene` no mutava `info::ctx.num_piramide = 0` al terminar, el fiber tornava a crear la mateixa escena — loop infinit. El `Go()` vell ho feia post-switch. Step 4.
4. Skip per tecla durant el revelat del logo nou saltava només les lletres i executava igualment `doIntroSprites`. El vell retornava abans de cridar doIntroSprites. Fix al Step 4: `Phase::Done` direct en skip, `Phase::Delegate` només per terminació natural.
---
## Context
Las fases 07b del plan anterior están completas. El runtime de AEE ya es moderno: fibers cooperativos, audio streaming sin `SDL_AddTimer`, callbacks `SDL_AppInit/Iterate/Event/Quit`, C++ idiomático en la capa jail. Lo que queda de *legacy pesado* es [source/game/modulesequence.cpp](source/game/modulesequence.cpp): **1309 líneas** con 9 funciones de cinemáticas lineales, 38+ `wait_frame_or_skip()` calls, constantes mágicas esparcidas, tres sub-variantes aleatorias en `doIntroSprites`, y código procedural difícil de editar.
Un refactor mecánico de eso no tiene sentido — las escenas son contenido, no plumbing. Cada una tiene su propia lógica específica y no se benefician de una state machine genérica ni de sed. Lo que sí tiene sentido es **reescribirlas de arriba a abajo** sobre una capa fina `scenes::` reutilizable (Timeline, SpriteMover, FrameAnimator, PaletteFade, surface handle RAII), convirtiendo cada función en una clase `Scene`. Cada escena migrada elimina su código legacy del modulesequence, hasta que la función Go() sólo quede como un delegador hacia el registry.
**Objetivos**:
1. Capa `scenes::` **pequeña y reutilizable** — helpers obvios, sin sobreingeniería, reusando [easing.hpp](source/utils/easing.hpp) y los `JD8_*` existentes.
2. Cada escena nueva: **~2080 líneas** de código declarativo (vs los cientos actuales).
3. **Fácil de añadir escenas nuevas** — derivar de `scenes::Scene`, llenar un Timeline o un `tick()` directo, registrar en el `SceneRegistry`.
4. **Time-based**: todo `delta_ms` explícito. Las escenas no tocan fibers, no tienen whiles, no llaman `JG_ShouldUpdate`.
5. **Migración gradual**: el fiber existente sigue corriendo por debajo. Las escenas nuevas se ejecutan *dentro* del fiber (por debajo del capó) pero su código es puro tick-based. Cuando las 9 estén migradas + ModuleGame también, el fiber se elimina de una pieza.
6. **Zero regresiones visuales** — cada escena nueva debe verse/sonar indistinguible de la vieja antes de eliminar el código legacy asociado.
---
## Capa `scenes::` — API
Namespace `scenes::` (plano, consistente con `Overlay::`, `Screen::`, `Menu::`).
### `scenes::Scene` — interfaz base [source/scenes/scene.hpp]
```cpp
class Scene {
public:
virtual ~Scene() = default;
// Llamado una vez cuando el Director la activa. Buen sitio para arrancar
// música o disparar un fade-in. Los assets pueden cargarse aquí o en el
// constructor (ambos válidos).
virtual void onEnter() {}
// Un paso de la escena. No debe bloquear, no debe llamar a JD8_Flip
// (lo hace el caller). delta_ms = tiempo real transcurrido desde el
// tick anterior.
virtual void tick(int delta_ms) = 0;
// True cuando la escena ha acabado y el Director debe pasar a la siguiente.
virtual bool done() const = 0;
// Valor de retorno equivalente al int que devolvía Go(). El caller lo
// usa para decidir el siguiente módulo. Consultado sólo cuando done().
virtual int nextState() const { return 1; }
};
```
### `scenes::Timeline` — secuencia de steps [source/scenes/timeline.hpp]
```cpp
class Timeline {
public:
using StepFn = std::function<void(float progress_0_1)>;
// Step con duración y callback que recibe el progreso [0..1] cada tick.
// Si fn es nullptr, el step es una espera pura.
Timeline& step(int duration_ms, StepFn fn = nullptr);
// Step que se ejecuta una sola vez al entrar (pinta algo estático y listo).
Timeline& once(std::function<void()> fn);
void tick(int delta_ms);
void skip(); // marca todos los steps restantes como done inmediatamente
void reset();
bool done() const;
int currentStepIndex() const;
float currentProgress() const; // 0..1 dentro del step actual
};
```
### `scenes::SpriteMover` — movimiento 2D con easing [source/scenes/sprite_mover.hpp]
```cpp
class SpriteMover {
public:
using EaseFn = float(*)(float);
void moveTo(int x0, int y0, int x1, int y1, int duration_ms,
EaseFn ease = Easing::linear);
void tick(int delta_ms);
int x() const;
int y() const;
bool done() const;
};
```
No gestiona surfaces — sólo posición. La escena hace `JD8_BlitCK(mover.x(), mover.y(), gfx, ...)` ella misma. Reutilizable para el coche de créditos, slides, Sam caminando, etc.
### `scenes::FrameAnimator` — iteración de frames subsampleados [source/scenes/frame_animator.hpp]
```cpp
class FrameAnimator {
public:
FrameAnimator(int num_frames, int frame_ms, bool loop = true);
void tick(int delta_ms);
int frame() const; // índice [0, num_frames)
bool done() const; // sólo relevante si loop=false
void reset();
};
```
Cubre camello (8 frames × 4 ticks), palmeras (4 × 8 ticks), Sam caminando con `(i/5) % fr`.
### `scenes::PaletteFade` — wrapper time-based de `JD8_Fade*` [source/scenes/palette_fade.hpp]
```cpp
class PaletteFade {
public:
void startFadeOut();
void startFadeTo(JD8_Palette target);
void tick(int delta_ms); // avanza un step de fade por tick
bool active() const;
bool done() const;
};
```
Wrapper sobre `JD8_FadeStartOut` / `JD8_FadeStartToPal` / `JD8_FadeTickStep` que ya existen.
### `scenes::SurfaceHandle` — RAII para `JD8_Surface` [source/scenes/surface_handle.hpp]
```cpp
class SurfaceHandle {
public:
SurfaceHandle() = default;
explicit SurfaceHandle(const char* file);
~SurfaceHandle();
SurfaceHandle(const SurfaceHandle&) = delete;
SurfaceHandle& operator=(const SurfaceHandle&) = delete;
SurfaceHandle(SurfaceHandle&&) noexcept;
SurfaceHandle& operator=(SurfaceHandle&&) noexcept;
operator JD8_Surface() const; // conversión implícita → pasable a JD8_Blit*
JD8_Surface get() const;
bool valid() const;
void reset(const char* file); // libera + recarga (doSecreta lo necesita)
};
```
### `scenes::SceneRegistry` — factory [source/scenes/scene_registry.hpp/cpp]
```cpp
class SceneRegistry {
public:
using Factory = std::function<std::unique_ptr<Scene>()>;
// Llamado al boot para registrar cada escena migrada.
void registerScene(int state_key, Factory f);
// Intenta crear la escena para un state dado. nullptr si no registrada.
// El caller (gameFiberEntry) cae al viejo Go() legacy si devuelve null.
std::unique_ptr<Scene> tryCreate(int state_key) const;
// Singleton accedido desde el Director al boot.
static SceneRegistry& instance();
};
```
El `state_key` es un valor sintético que combina `info::ctx.num_piramide` con el módulo objetivo (sequence vs game). Los valores exactos los resolvemos al implementar — podría ser el propio `num_piramide` si es suficiente para distinguir (255=intro, 0=menu, 1/7=slides, 2-5=banner, 6=secreta, 8=credits, 100=mort).
---
## Organización de archivos
```
source/scenes/
├── scene.hpp
├── scene_registry.hpp/.cpp
├── timeline.hpp/.cpp
├── sprite_mover.hpp/.cpp
├── frame_animator.hpp/.cpp
├── palette_fade.hpp/.cpp
├── surface_handle.hpp/.cpp
├── mort_scene.hpp/.cpp # orden de migración
├── banner_scene.hpp/.cpp
├── menu_scene.hpp/.cpp
├── intro_new_logo_scene.hpp/.cpp
├── slides_scene.hpp/.cpp
├── credits_scene.hpp/.cpp
├── secreta_scene.hpp/.cpp
├── intro_scene.hpp/.cpp
└── intro_sprites_scene.hpp/.cpp
```
Estructura plana — sin subdirectorios `helpers/` o `concrete/`. Añadir archivo nuevo = una línea al `CMakeLists.txt`.
---
## Integración con el Director existente
**No creo un Director nuevo**. Modifico [source/core/system/director.cpp](source/core/system/director.cpp) — concretamente `gameFiberEntry()` en el namespace anónimo — para que consulte el `SceneRegistry` antes de caer al viejo `ModuleSequence::Go()`:
```cpp
// pseudocodigo dentro de gameFiberEntry()
int state = 1;
while (state != -1 && !JG_Quitting()) {
// Intentamos resolver la escena por el state actual.
if (auto scene = SceneRegistry::instance().tryCreate(info::ctx.num_piramide)) {
scene->onEnter();
Uint32 last = SDL_GetTicks();
while (!scene->done() && !JG_Quitting()) {
Uint32 now = SDL_GetTicks();
scene->tick(static_cast<int>(now - last));
last = now;
JD8_Flip(); // yields al Director (presenta con overlay encima)
}
state = scene->nextState();
continue;
}
// Fallback: todavía no migrada, usa el Go() legacy
if (state == 1) {
auto* ms = new ModuleSequence();
state = ms->Go();
delete ms;
} else if (state == 0) {
auto* mg = new ModuleGame();
state = mg->Go();
delete mg;
}
}
```
**Claves**:
- Las escenas nuevas son puras tick-based. `tick(delta_ms)` no sabe del fiber.
- El mini-while que las ejecuta vive en `gameFiberEntry`, que sí corre dentro del fiber. `JD8_Flip()` es el que hace el yield al Director — igual que ahora.
- Cuando todas las escenas + `ModuleGame` estén migradas, este mini-while migra al `Director::iterate()` directo y se elimina `gameFiberEntry` + `GameFiber::*`. Pero eso no es para esta tanda.
- Registro de escenas: se hace en `Director::init()` llamando a `SceneRegistry::instance().registerScene(state_key, []{ return std::make_unique<scenes::MortScene>(); })` para cada escena ya migrada.
---
## Orden de migración (simple → complejo)
Cada paso = una PR / commit / validación visual antes de seguir. Al migrar una escena, **se elimina la función legacy correspondiente** de modulesequence.cpp.
### Step 0 — Infraestructura
Crear los archivos de la capa `scenes::` (scene, timeline, sprite_mover, frame_animator, palette_fade, surface_handle, scene_registry) sin ninguna escena concreta todavía. Compilar para confirmar que la capa es sólida.
### Step 1 — `MortScene` (complejidad **Baja**)
Reemplaza `ModuleSequence::doMort()`. ~15 líneas originales: blit fullscreen `gameover.gif` + `JD8_FadeToPal` + música `00000001.ogg` + espera 1000ms o tecla + `info::ctx.vida = 5`. Es la primera víctima: valida la API mínima (`Scene` + `PaletteFade` + `SurfaceHandle`).
### Step 2 — `BannerScene` (complejidad **Baja**)
Reemplaza `ModuleSequence::doBanner()`. Blits estáticos "PIRÀMIDE X" + número + fade entrada + espera 5000ms + `JA_FadeOutMusic(250)` + fade salida. Primera prueba de `Timeline::step()` con `once()`.
### Step 3 — `MenuScene` (complejidad **Media-Baja**)
Reemplaza `ModuleSequence::doMenu()`. Primera prueba de `FrameAnimator` (palmeras, camello, horizonte). Bucle infinito hasta input. Lee/escribe `info::ctx.pepe_activat` y `info::ctx.nou_personatge`. Texto condicional con `Locale::get`.
### Step 4 — `IntroNewLogoScene` (complejidad **Media**)
Reemplaza `ModuleSequence::doIntroNewLogo()`. Revelado letra a letra (9 letras × 150ms) + cursor parpadeando + logo completo + ciclo de paleta 256 pasos. Timeline con 20+ steps. Mantiene la llamada final a `doIntroSprites` (que aún no está migrada — delegación legacy temporal).
### Step 5 — `SlidesScene` (complejidad **Media**)
Reemplaza `ModuleSequence::doSlides()`. 3 slides con scroll entrada-derecha + espera + scroll salida-izquierda. Primera prueba seria de `SpriteMover` con `Easing::outCubic`. Elige asset según `info::ctx.num_piramide` + `info::ctx.diners`. Fade de música al final.
### Step 6 — `CreditsScene` (complejidad **Media**)
Reemplaza `ModuleSequence::doCredits()`. Scroll vertical largo (~3100 frames = ~62s a 20ms) + scroll parallax condicional si `info::ctx.diamants == 16` con animación de coche. Escribe `info::ctx.nou_personatge = true` y crea `trick.ini`.
### Step 7 — `SecretaScene` (complejidad **Media-Alta**)
Reemplaza `ModuleSequence::doSecreta()`. 11 estados originales: scroll + recarga de asset a mitad (`SurfaceHandle::reset`) + animación RGB dinámica del rojo (`JD8_SetPaletteColor`). Primera escena que usa `SurfaceHandle::reset()`.
### Step 8 — `IntroScene` (complejidad **Media-Alta**)
Reemplaza `ModuleSequence::doIntro()` (el logo JAILGAMES legacy). 11 pasos lineales de construcción del wordmark + ciclo de paleta + delegación a `IntroSpritesScene`. Timeline con muchos `once()` + `step()`.
### Step 9 — `IntroSpritesScene` (complejidad **Alta**)
Reemplaza `ModuleSequence::doIntroSprites()`. La bestia: `switch(rand() % 3)` con 3 variantes completamente distintas (~900-1100 frames cada una). Cada variante tiene 6-8 loops anidados. Aquí probablemente hace falta combinar `Timeline` + `SpriteMover` + `FrameAnimator` + lógica ad-hoc. Si la API no escala limpia, se acepta que esta escena tenga `tick()` manual sin Timeline.
### Step 10 — Limpieza final
En este punto `ModuleSequence` ya no tiene ninguna función `doX()` — sólo el `Go()` que delega al registry. Se puede:
- Eliminar `ModuleSequence` completo y mover el dispatch al `gameFiberEntry` directo.
- Eliminar el helper `wait_frame_or_skip()`.
- Eliminar el include de `fiber.hpp` desde `jgame.cpp` si `ModuleGame` también es tick-based (fuera de scope de este plan, pero queda preparado).
---
## Invariantes por escena
Cada paso **debe cumplir**:
1. Visualmente indistinguible de la vieja versión (mismo timing, mismas transiciones, mismo feel). Validar jugándolo.
2. Skip por tecla funciona idéntico (misma tecla, mismo momento).
3. Build nativo compila limpio, sin warnings nuevos.
4. Audio sigue: música arranca, fades suaves, no hay cortes.
5. Overlay sigue animándose encima (pause, notificaciones, render info) — lo hace el Director sin tocar la escena.
6. La función legacy `doX()` se elimina en el mismo commit que su `XScene`, no se deja código muerto.
---
## Fuera de scope (explícito)
- **`ModuleGame`** (gameplay puro). Sigue con Go() + fiber. Se migrará más tarde con otra estructura (probablemente no Scene — es interactivo y no lineal).
- **Emscripten fiber backend** + build WASM (fases 7c/7d del plan anterior). Cuando estén migradas las escenas + ModuleGame, los fibers se eliminan y este punto se vuelve trivial.
- **Fase 6** (time-based total con accumulator pattern). La saltamos — no aporta valor real con el framerate actual.
- **Multi-language** de textos en escenas. Se usa `Locale::get` directamente donde haga falta, sin envoltorio nuevo.
---
## Critical files
| Archivo | Step | Tipo |
|---|---|---|
| [source/scenes/scene.hpp](source/scenes/scene.hpp) | 0 | nuevo, interfaz base |
| [source/scenes/timeline.hpp](source/scenes/timeline.hpp) + .cpp | 0 | nuevo, helper central |
| [source/scenes/sprite_mover.hpp](source/scenes/sprite_mover.hpp) + .cpp | 0 | nuevo |
| [source/scenes/frame_animator.hpp](source/scenes/frame_animator.hpp) + .cpp | 0 | nuevo |
| [source/scenes/palette_fade.hpp](source/scenes/palette_fade.hpp) + .cpp | 0 | nuevo |
| [source/scenes/surface_handle.hpp](source/scenes/surface_handle.hpp) + .cpp | 0 | nuevo, RAII |
| [source/scenes/scene_registry.hpp](source/scenes/scene_registry.hpp) + .cpp | 0 | nuevo, factory |
| [source/scenes/*_scene.hpp](source/scenes/) + .cpp | 19 | una por paso |
| [source/core/system/director.cpp](source/core/system/director.cpp) | 0 | modificar `gameFiberEntry` |
| [source/game/modulesequence.cpp](source/game/modulesequence.cpp) | 19 | borrar funciones `doX()` una a una |
| [CMakeLists.txt](CMakeLists.txt) | 09 | añadir archivos nuevos |
## Reusables existentes
- [source/utils/easing.hpp](source/utils/easing.hpp) — `Easing::linear`, `outQuad`, `outCubic`, `inOutQuad`, `lerp`, `lerpInt`. Usados por `SpriteMover` y cualquier step de `Timeline` que reciba progress.
- [source/core/jail/jdraw8.hpp](source/core/jail/jdraw8.hpp) — `JD8_FadeStartOut`, `JD8_FadeStartToPal`, `JD8_FadeTickStep`, `JD8_FadeIsActive`. Usados por `PaletteFade`.
- [source/core/jail/jail_audio.hpp](source/core/jail/jail_audio.hpp) — `JA_PlayMusic`, `JA_FadeOutMusic`, `JA_PauseMusic`, `JA_ResumeMusic`.
- [source/core/locale/locale.hpp](source/core/locale/locale.hpp) — `Locale::get("key")` para strings de UI en las escenas.
- [source/core/rendering/overlay.hpp](source/core/rendering/overlay.hpp) — sigue siendo responsabilidad del Director; las escenas no tocan overlay.
- [source/core/jail/jinput.hpp](source/core/jail/jinput.hpp) — `JI_AnyKey`, `JI_KeyPressed` para detectar skip y navegación de menú.
---
## Ejemplos concretos
### `MortScene` (Step 1) — ~20 líneas de lógica
```cpp
// mort_scene.hpp
namespace scenes {
class MortScene : public Scene {
public:
void onEnter() override;
void tick(int delta_ms) override;
bool done() const override { return done_; }
int nextState() const override { return 1; } // igual que doMort → vuelve a seq
private:
SurfaceHandle gfx_;
PaletteFade fade_;
int remaining_ms_ = 1000;
bool done_ = false;
};
}
// mort_scene.cpp
void MortScene::onEnter() {
// Lo que hacía ModuleSequence::doMort() linealmente, declarativo.
int size = 0;
char* buf = file_getfilebuffer("00000001.ogg", size);
JA_PlayMusic(JA_LoadMusic((Uint8*)buf, size, "00000001.ogg"));
JI_DisableKeyboard(60);
info::ctx.vida = 5;
gfx_ = SurfaceHandle("gameover.gif");
JD8_Palette pal = JD8_LoadPalette("gameover.gif");
JD8_ClearScreen(0);
JD8_Blit(gfx_);
fade_.startFadeTo(pal);
}
void MortScene::tick(int delta_ms) {
fade_.tick(delta_ms);
if (JI_AnyKey()) { done_ = true; return; }
remaining_ms_ -= delta_ms;
if (remaining_ms_ <= 0) done_ = true;
}
```
### `BannerScene` (Step 2) — Timeline + fades
```cpp
void BannerScene::onEnter() {
play_music("00000004.ogg");
gfx_ = SurfaceHandle("ffase.gif");
JD8_Palette pal = JD8_LoadPalette("ffase.gif");
timeline_
.once([this]{
JD8_ClearScreen(0);
// blits del banner + número según info::ctx.num_piramide
fade_in_.startFadeTo(pal);
})
.step(5000); // espera. Cualquier tecla hace timeline_.skip().
}
void BannerScene::tick(int delta_ms) {
fade_in_.tick(delta_ms);
if (!timeline_.done()) {
if (JI_AnyKey()) timeline_.skip();
timeline_.tick(delta_ms);
if (timeline_.done() && !fade_out_started_) {
JA_FadeOutMusic(250);
fade_out_.startFadeOut();
fade_out_started_ = true;
}
} else {
fade_out_.tick(delta_ms);
}
}
bool BannerScene::done() const { return timeline_.done() && fade_out_.done(); }
```
---
## Verification
Tras **cada step**:
1. `cmake --build build` limpio, sin warnings nuevos.
2. Ejecutar el juego entero desde intro hasta muerte, con atención específica a la escena migrada. Comparar con un git stash temporal del viejo código si hace falta.
3. **Skip por tecla** en la escena migrada — debe saltar a la siguiente igual que antes.
4. **Pausa F11** durante la escena — el juego se congela, el overlay sigue animándose.
5. **Menú F12** durante la escena — debe abrir encima.
6. **Cerrar ventana** durante la escena — responde al instante (sin el viejo bug de congelamiento).
7. **Audio** — la música debe arrancar cuando toca, los fades suaves, sin cortes.
8. **ESC doble-press** — sale limpiamente.
Tras el **step 10** (limpieza final):
- `modulesequence.cpp` tiene ~50 líneas (solo `Go()` mínimo) o desaparece del todo.
- El juego entero es jugable de principio a fin.
- FPS estable ≥60 con vsync.
- Cero referencias a `wait_frame_or_skip` en el código.
---
## Cadencia
Igual que antes: **paso a paso con pausa**. Cada step (09) se cierra con build verde + validación visual antes de arrancar el siguiente. No encadeno pasos automáticamente.

File diff suppressed because it is too large Load Diff

207
source/core/audio/audio.cpp Normal file
View File

@@ -0,0 +1,207 @@
#include "core/audio/audio.hpp"
#include <SDL3/SDL.h> // Para SDL_GetError, SDL_Init
#include <algorithm> // Para clamp
#include <iostream> // Para std::cout
// Implementación de stb_vorbis (debe estar ANTES de incluir jail_audio.hpp).
// clang-format off
#undef STB_VORBIS_HEADER_ONLY
#include "external/stb_vorbis.c"
// stb_vorbis.c filtra les macros L, C i R (i PLAYBACK_*) al TU. Les netegem
// perquè xocarien amb noms de paràmetres de plantilla en altres headers.
#undef L
#undef C
#undef R
#undef PLAYBACK_MONO
#undef PLAYBACK_LEFT
#undef PLAYBACK_RIGHT
// clang-format on
#include "core/audio/audio_adapter.hpp" // Para AudioResource::getMusic/getSound
#include "core/audio/jail_audio.hpp" // Para JA_*
#include "game/options.hpp" // Para Options::audio
// Singleton
std::unique_ptr<Audio> Audio::instance;
// Inicializa la instancia única del singleton
void Audio::init() { Audio::instance = std::unique_ptr<Audio>(new Audio()); }
// Libera la instancia
void Audio::destroy() { Audio::instance.reset(); }
// Obtiene la instancia
auto Audio::get() -> Audio* { return Audio::instance.get(); }
// Constructor
Audio::Audio() { initSDLAudio(); }
// Destructor
Audio::~Audio() {
JA_Quit();
}
// Método principal
void Audio::update() {
JA_Update();
// Sincronizar estado: detectar cuando la música se para (ej. fade-out completado)
if (instance && instance->music_.state == MusicState::PLAYING && JA_GetMusicState() != JA_MUSIC_PLAYING) {
instance->music_.state = MusicState::STOPPED;
}
}
// Reproduce la música por nombre (con crossfade opcional)
void Audio::playMusic(const std::string& name, const int loop, const int crossfade_ms) {
bool new_loop = (loop != 0);
// Si ya está sonando exactamente la misma pista y mismo modo loop, no hacemos nada
if (music_.state == MusicState::PLAYING && music_.name == name && music_.loop == new_loop) {
return;
}
if (!music_enabled_) return;
auto* resource = AudioResource::getMusic(name);
if (resource == nullptr) return;
if (crossfade_ms > 0 && music_.state == MusicState::PLAYING) {
JA_CrossfadeMusic(resource, crossfade_ms, loop);
} else {
if (music_.state == MusicState::PLAYING) {
JA_StopMusic();
}
JA_PlayMusic(resource, loop);
}
music_.name = name;
music_.loop = new_loop;
music_.state = MusicState::PLAYING;
}
// Reproduce la música por puntero (con crossfade opcional)
void Audio::playMusic(JA_Music_t* music, const int loop, const int crossfade_ms) {
if (!music_enabled_ || music == nullptr) return;
if (crossfade_ms > 0 && music_.state == MusicState::PLAYING) {
JA_CrossfadeMusic(music, crossfade_ms, loop);
} else {
if (music_.state == MusicState::PLAYING) {
JA_StopMusic();
}
JA_PlayMusic(music, loop);
}
music_.name.clear(); // nom desconegut quan es passa per punter
music_.loop = (loop != 0);
music_.state = MusicState::PLAYING;
}
// Pausa la música
void Audio::pauseMusic() {
if (music_enabled_ && music_.state == MusicState::PLAYING) {
JA_PauseMusic();
music_.state = MusicState::PAUSED;
}
}
// Continua la música pausada
void Audio::resumeMusic() {
if (music_enabled_ && music_.state == MusicState::PAUSED) {
JA_ResumeMusic();
music_.state = MusicState::PLAYING;
}
}
// Detiene la música
void Audio::stopMusic() {
if (music_enabled_) {
JA_StopMusic();
music_.state = MusicState::STOPPED;
}
}
// Reproduce un sonido por nombre
void Audio::playSound(const std::string& name, Group group) const {
if (sound_enabled_) {
JA_PlaySound(AudioResource::getSound(name), 0, static_cast<int>(group));
}
}
// Reproduce un sonido por puntero directo
void Audio::playSound(JA_Sound_t* sound, Group group) const {
if (sound_enabled_ && sound != nullptr) {
JA_PlaySound(sound, 0, static_cast<int>(group));
}
}
// Detiene todos los sonidos
void Audio::stopAllSounds() const {
if (sound_enabled_) {
JA_StopChannel(-1);
}
}
// Realiza un fundido de salida de la música
void Audio::fadeOutMusic(int milliseconds) const {
if (music_enabled_ && getRealMusicState() == MusicState::PLAYING) {
JA_FadeOutMusic(milliseconds);
}
}
// Consulta directamente el estado real de la música en jailaudio
auto Audio::getRealMusicState() -> MusicState {
JA_Music_state ja_state = JA_GetMusicState();
switch (ja_state) {
case JA_MUSIC_PLAYING:
return MusicState::PLAYING;
case JA_MUSIC_PAUSED:
return MusicState::PAUSED;
case JA_MUSIC_STOPPED:
case JA_MUSIC_INVALID:
case JA_MUSIC_DISABLED:
default:
return MusicState::STOPPED;
}
}
// Establece el volumen de los sonidos (float 0.0..1.0)
void Audio::setSoundVolume(float sound_volume, Group group) const {
sound_volume = std::clamp(sound_volume, MIN_VOLUME, MAX_VOLUME);
const bool active = enabled_ && sound_enabled_;
const float CONVERTED_VOLUME = active ? sound_volume * Options::audio.volume : 0.0F;
JA_SetSoundVolume(CONVERTED_VOLUME, static_cast<int>(group));
}
// Establece el volumen de la música (float 0.0..1.0)
void Audio::setMusicVolume(float music_volume) const {
music_volume = std::clamp(music_volume, MIN_VOLUME, MAX_VOLUME);
const bool active = enabled_ && music_enabled_;
const float CONVERTED_VOLUME = active ? music_volume * Options::audio.volume : 0.0F;
JA_SetMusicVolume(CONVERTED_VOLUME);
}
// Aplica la configuración
void Audio::applySettings() {
enable(Options::audio.enabled);
}
// Establecer estado general
void Audio::enable(bool value) {
enabled_ = value;
setSoundVolume(enabled_ ? Options::audio.sound.volume : MIN_VOLUME);
setMusicVolume(enabled_ ? Options::audio.music.volume : MIN_VOLUME);
}
// Inicializa SDL Audio
void Audio::initSDLAudio() {
if (!SDL_Init(SDL_INIT_AUDIO)) {
std::cout << "SDL_AUDIO could not initialize! SDL Error: " << SDL_GetError() << '\n';
} else {
JA_Init(FREQUENCY, SDL_AUDIO_S16LE, 2);
enable(Options::audio.enabled);
}
}

115
source/core/audio/audio.hpp Normal file
View File

@@ -0,0 +1,115 @@
#pragma once
#include <cstdint> // Para int8_t, uint8_t
#include <memory> // Para std::unique_ptr
#include <string> // Para string
#include <utility> // Para move
// --- Clase Audio: gestor de audio (singleton) ---
// Implementació canònica, byte-idèntica entre projectes.
// Els volums es manegen internament com a float 0.01.0; la capa de
// presentació (menús, notificacions) usa les helpers toPercent/fromPercent
// per mostrar 0100 a l'usuari.
class Audio {
public:
// --- Enums ---
enum class Group : std::int8_t {
ALL = -1, // Todos los grupos
GAME = 0, // Sonidos del juego
INTERFACE = 1 // Sonidos de la interfaz
};
enum class MusicState : std::uint8_t {
PLAYING, // Reproduciendo música
PAUSED, // Música pausada
STOPPED, // Música detenida
};
// --- Constantes ---
static constexpr float MAX_VOLUME = 1.0F; // Volumen máximo (float 0..1)
static constexpr float MIN_VOLUME = 0.0F; // Volumen mínimo (float 0..1)
static constexpr float VOLUME_STEP = 0.05F; // Pas estàndard per a UI (5%)
static constexpr int FREQUENCY = 48000; // Frecuencia de audio
static constexpr int DEFAULT_CROSSFADE_MS = 1500; // Duració del crossfade per defecte (ms)
// --- Singleton ---
static void init(); // Inicializa el objeto Audio
static void destroy(); // Libera el objeto Audio
static auto get() -> Audio*; // Obtiene el puntero al objeto Audio
~Audio(); // Destructor (públic per a std::unique_ptr)
Audio(const Audio&) = delete; // Evitar copia
auto operator=(const Audio&) -> Audio& = delete; // Evitar asignación
static void update(); // Actualización del sistema de audio
// --- Control de música ---
void playMusic(const std::string& name, int loop = -1, int crossfade_ms = 0); // Reproducir música por nombre (con crossfade opcional)
void playMusic(struct JA_Music_t* music, int loop = -1, int crossfade_ms = 0); // Reproducir música por puntero (con crossfade opcional)
void pauseMusic(); // Pausar reproducción de música
void resumeMusic(); // Continua la música pausada
void stopMusic(); // Detener completamente la música
void fadeOutMusic(int milliseconds) const; // Fundido de salida de la música
// --- Control de sonidos ---
void playSound(const std::string& name, Group group = Group::GAME) const; // Reproducir sonido puntual por nombre
void playSound(struct JA_Sound_t* sound, Group group = Group::GAME) const; // Reproducir sonido puntual por puntero
void stopAllSounds() const; // Detener todos los sonidos
// --- Control de volumen (API interna: float 0.0..1.0) ---
void setSoundVolume(float volume, Group group = Group::ALL) const; // Ajustar volumen de efectos
void setMusicVolume(float volume) const; // Ajustar volumen de música
// --- Helpers de conversió per a la capa de presentació ---
// UI (menús, notificacions) manega enters 0..100; internament viu float 0..1.
static constexpr auto toPercent(float volume) -> int {
return static_cast<int>(volume * 100.0F + 0.5F);
}
static constexpr auto fromPercent(int percent) -> float {
return static_cast<float>(percent) / 100.0F;
}
// --- Configuración general ---
void enable(bool value); // Establecer estado general
void toggleEnabled() { enabled_ = !enabled_; } // Alternar estado general
void applySettings(); // Aplica la configuración
// --- Configuración de sonidos ---
void enableSound() { sound_enabled_ = true; } // Habilitar sonidos
void disableSound() { sound_enabled_ = false; } // Deshabilitar sonidos
void enableSound(bool value) { sound_enabled_ = value; } // Establecer estado de sonidos
void toggleSound() { sound_enabled_ = !sound_enabled_; } // Alternar estado de sonidos
// --- Configuración de música ---
void enableMusic() { music_enabled_ = true; } // Habilitar música
void disableMusic() { music_enabled_ = false; } // Deshabilitar música
void enableMusic(bool value) { music_enabled_ = value; } // Establecer estado de música
void toggleMusic() { music_enabled_ = !music_enabled_; } // Alternar estado de música
// --- Consultas de estado ---
[[nodiscard]] auto isEnabled() const -> bool { return enabled_; }
[[nodiscard]] auto isSoundEnabled() const -> bool { return sound_enabled_; }
[[nodiscard]] auto isMusicEnabled() const -> bool { return music_enabled_; }
[[nodiscard]] auto getMusicState() const -> MusicState { return music_.state; }
[[nodiscard]] static auto getRealMusicState() -> MusicState;
[[nodiscard]] auto getCurrentMusicName() const -> const std::string& { return music_.name; }
private:
// --- Tipos anidados ---
struct Music {
MusicState state{MusicState::STOPPED}; // Estado actual de la música
std::string name; // Última pista de música reproducida
bool loop{false}; // Indica si se reproduce en bucle
};
// --- Métodos ---
Audio(); // Constructor privado
void initSDLAudio(); // Inicializa SDL Audio
// --- Variables miembro ---
static std::unique_ptr<Audio> instance; // Instancia única de Audio
Music music_; // Estado de la música
bool enabled_{true}; // Estado general del audio
bool sound_enabled_{true}; // Estado de los efectos de sonido
bool music_enabled_{true}; // Estado de la música
};

View File

@@ -0,0 +1,15 @@
#include "core/audio/audio_adapter.hpp"
#include "core/resources/resource_cache.hpp"
namespace AudioResource {
JA_Music_t* getMusic(const std::string& name) {
return Resource::Cache::get()->getMusic(name);
}
JA_Sound_t* getSound(const std::string& name) {
return Resource::Cache::get()->getSound(name);
}
} // namespace AudioResource

View File

@@ -0,0 +1,17 @@
#pragma once
// --- Audio Resource Adapter ---
// Aquest fitxer exposa una interfície comuna a Audio per obtenir JA_Music_t* /
// JA_Sound_t* per nom. Cada projecte la implementa en audio_adapter.cpp
// delegant al seu singleton de recursos (Resource::get(), Resource::Cache::get(),
// etc.). Això permet que audio.hpp/audio.cpp siguin idèntics entre projectes.
#include <string> // Para string
struct JA_Music_t;
struct JA_Sound_t;
namespace AudioResource {
JA_Music_t* getMusic(const std::string& name);
JA_Sound_t* getSound(const std::string& name);
} // namespace AudioResource

View File

@@ -0,0 +1,679 @@
#pragma once
// --- Includes ---
#include <SDL3/SDL.h>
#include <stdint.h> // Para uint32_t, uint8_t
#include <stdio.h> // Para NULL, fseek, fclose, fopen, fread, ftell, FILE, SEEK_END, SEEK_SET
#include <stdlib.h> // Para free, malloc
#include <iostream> // Para std::cout
#include <memory> // Para std::unique_ptr
#include <string> // Para std::string
#include <vector> // Para std::vector
#define STB_VORBIS_HEADER_ONLY
#include "external/stb_vorbis.c" // Para stb_vorbis_open_memory i streaming
// Deleter stateless per a buffers reservats amb `SDL_malloc` / `SDL_LoadWAV*`.
// Compatible amb `std::unique_ptr<Uint8[], SDLFreeDeleter>` — zero size
// overhead gràcies a EBO, igual que un unique_ptr amb default_delete.
struct SDLFreeDeleter {
void operator()(Uint8* p) const noexcept {
if (p) SDL_free(p);
}
};
// --- Public Enums ---
enum JA_Channel_state {
JA_CHANNEL_INVALID,
JA_CHANNEL_FREE,
JA_CHANNEL_PLAYING,
JA_CHANNEL_PAUSED,
JA_SOUND_DISABLED,
};
enum JA_Music_state {
JA_MUSIC_INVALID,
JA_MUSIC_PLAYING,
JA_MUSIC_PAUSED,
JA_MUSIC_STOPPED,
JA_MUSIC_DISABLED,
};
// --- Struct Definitions ---
#define JA_MAX_SIMULTANEOUS_CHANNELS 20
#define JA_MAX_GROUPS 2
struct JA_Sound_t {
SDL_AudioSpec spec{SDL_AUDIO_S16, 2, 48000};
Uint32 length{0};
// Buffer descomprimit (PCM) propietat del sound. Reservat per SDL_LoadWAV
// via SDL_malloc; el deleter `SDLFreeDeleter` allibera amb SDL_free.
std::unique_ptr<Uint8[], SDLFreeDeleter> buffer;
};
struct JA_Channel_t {
JA_Sound_t* sound{nullptr};
int pos{0};
int times{0};
int group{0};
SDL_AudioStream* stream{nullptr};
JA_Channel_state state{JA_CHANNEL_FREE};
};
struct JA_Music_t {
SDL_AudioSpec spec{SDL_AUDIO_S16, 2, 48000};
// OGG comprimit en memòria. Propietat nostra; es copia des del buffer
// d'entrada una sola vegada en JA_LoadMusic i es descomprimix en chunks
// per streaming. Com que stb_vorbis guarda un punter persistent al
// `.data()` d'aquest vector, no el podem resize'jar un cop establert
// (una reallocation invalidaria el punter que el decoder conserva).
std::vector<Uint8> ogg_data;
stb_vorbis* vorbis{nullptr}; // handle del decoder, viu tot el cicle del JA_Music_t
std::string filename;
int times{0}; // loops restants (-1 = infinit, 0 = un sol play)
SDL_AudioStream* stream{nullptr};
JA_Music_state state{JA_MUSIC_INVALID};
};
// --- Internal Global State (inline, C++17) ---
inline JA_Music_t* current_music{nullptr};
inline JA_Channel_t channels[JA_MAX_SIMULTANEOUS_CHANNELS];
inline SDL_AudioSpec JA_audioSpec{SDL_AUDIO_S16, 2, 48000};
inline float JA_musicVolume{1.0f};
inline float JA_soundVolume[JA_MAX_GROUPS];
inline bool JA_musicEnabled{true};
inline bool JA_soundEnabled{true};
inline SDL_AudioDeviceID sdlAudioDevice{0};
// --- Crossfade / Fade State ---
struct JA_FadeState {
bool active{false};
Uint64 start_time{0};
int duration_ms{0};
float initial_volume{0.0f};
};
struct JA_OutgoingMusic {
SDL_AudioStream* stream{nullptr};
JA_FadeState fade;
};
inline JA_OutgoingMusic outgoing_music;
inline JA_FadeState incoming_fade;
// --- Forward Declarations ---
inline void JA_StopMusic();
inline void JA_StopChannel(const int channel);
inline int JA_PlaySoundOnChannel(JA_Sound_t* sound, const int channel, const int loop = 0, const int group = 0);
inline void JA_CrossfadeMusic(JA_Music_t* music, int crossfade_ms, int loop = -1);
// --- Music streaming internals ---
// Bytes-per-sample per canal (sempre s16)
static constexpr int JA_MUSIC_BYTES_PER_SAMPLE = 2;
// Quants shorts decodifiquem per crida a get_samples_short_interleaved.
// 8192 shorts = 4096 samples/channel en estèreo ≈ 85ms de so a 48kHz.
static constexpr int JA_MUSIC_CHUNK_SHORTS = 8192;
// Umbral d'audio per davant del cursor de reproducció. Mantenim ≥ 0.5 s a
// l'SDL_AudioStream per absorbir jitter de frame i evitar underruns.
static constexpr float JA_MUSIC_LOW_WATER_SECONDS = 0.5f;
// Decodifica un chunk del vorbis i el volca a l'stream. Retorna samples
// decodificats per canal (0 = EOF de l'stream vorbis).
inline int JA_FeedMusicChunk(JA_Music_t* music) {
if (!music || !music->vorbis || !music->stream) return 0;
short chunk[JA_MUSIC_CHUNK_SHORTS];
const int num_channels = music->spec.channels;
const int samples_per_channel = stb_vorbis_get_samples_short_interleaved(
music->vorbis,
num_channels,
chunk,
JA_MUSIC_CHUNK_SHORTS);
if (samples_per_channel <= 0) return 0;
const int bytes = samples_per_channel * num_channels * JA_MUSIC_BYTES_PER_SAMPLE;
SDL_PutAudioStreamData(music->stream, chunk, bytes);
return samples_per_channel;
}
// Reompli l'stream fins que tinga ≥ JA_MUSIC_LOW_WATER_SECONDS bufferats.
// En arribar a EOF del vorbis, aplica el loop (times) o deixa drenar.
inline void JA_PumpMusic(JA_Music_t* music) {
if (!music || !music->vorbis || !music->stream) return;
const int bytes_per_second = music->spec.freq * music->spec.channels * JA_MUSIC_BYTES_PER_SAMPLE;
const int low_water_bytes = static_cast<int>(JA_MUSIC_LOW_WATER_SECONDS * static_cast<float>(bytes_per_second));
while (SDL_GetAudioStreamAvailable(music->stream) < low_water_bytes) {
const int decoded = JA_FeedMusicChunk(music);
if (decoded > 0) continue;
// EOF: si queden loops, rebobinar; si no, tallar i deixar drenar.
if (music->times != 0) {
stb_vorbis_seek_start(music->vorbis);
if (music->times > 0) music->times--;
} else {
break;
}
}
}
// Pre-carrega `duration_ms` de so dins l'stream actual abans que l'stream
// siga robat per outgoing_music (crossfade o fade-out). Imprescindible amb
// streaming: l'stream robat no es pot re-alimentar perquè perd la referència
// al seu vorbis decoder. No aplica loop — si el vorbis s'esgota abans, parem.
inline void JA_PreFillOutgoing(JA_Music_t* music, int duration_ms) {
if (!music || !music->vorbis || !music->stream) return;
const int bytes_per_second = music->spec.freq * music->spec.channels * JA_MUSIC_BYTES_PER_SAMPLE;
const int needed_bytes = static_cast<int>((static_cast<int64_t>(duration_ms) * bytes_per_second) / 1000);
while (SDL_GetAudioStreamAvailable(music->stream) < needed_bytes) {
const int decoded = JA_FeedMusicChunk(music);
if (decoded <= 0) break; // EOF: deixem drenar el que hi haja
}
}
// --- Core Functions ---
inline void JA_Update() {
// --- Outgoing music fade-out (crossfade o fade-out a silencio) ---
if (outgoing_music.stream && outgoing_music.fade.active) {
Uint64 now = SDL_GetTicks();
Uint64 elapsed = now - outgoing_music.fade.start_time;
if (elapsed >= (Uint64)outgoing_music.fade.duration_ms) {
SDL_DestroyAudioStream(outgoing_music.stream);
outgoing_music.stream = nullptr;
outgoing_music.fade.active = false;
} else {
float percent = (float)elapsed / (float)outgoing_music.fade.duration_ms;
SDL_SetAudioStreamGain(outgoing_music.stream, outgoing_music.fade.initial_volume * (1.0f - percent));
}
}
// --- Current music ---
if (JA_musicEnabled && current_music && current_music->state == JA_MUSIC_PLAYING) {
// Fade-in (parte de un crossfade)
if (incoming_fade.active) {
Uint64 now = SDL_GetTicks();
Uint64 elapsed = now - incoming_fade.start_time;
if (elapsed >= (Uint64)incoming_fade.duration_ms) {
incoming_fade.active = false;
SDL_SetAudioStreamGain(current_music->stream, JA_musicVolume);
} else {
float percent = (float)elapsed / (float)incoming_fade.duration_ms;
SDL_SetAudioStreamGain(current_music->stream, JA_musicVolume * percent);
}
}
// Streaming: rellenem l'stream fins al low-water-mark i parem si el
// vorbis s'ha esgotat i no queden loops.
JA_PumpMusic(current_music);
if (current_music->times == 0 && SDL_GetAudioStreamAvailable(current_music->stream) == 0) {
JA_StopMusic();
}
}
// --- Sound channels ---
if (JA_soundEnabled) {
for (int i = 0; i < JA_MAX_SIMULTANEOUS_CHANNELS; ++i)
if (channels[i].state == JA_CHANNEL_PLAYING) {
if (channels[i].times != 0) {
if ((Uint32)SDL_GetAudioStreamAvailable(channels[i].stream) < (channels[i].sound->length / 2)) {
SDL_PutAudioStreamData(channels[i].stream, channels[i].sound->buffer.get(), channels[i].sound->length);
if (channels[i].times > 0) channels[i].times--;
}
} else {
if (SDL_GetAudioStreamAvailable(channels[i].stream) == 0) JA_StopChannel(i);
}
}
}
}
inline void JA_Init(const int freq, const SDL_AudioFormat format, const int num_channels) {
JA_audioSpec = {format, num_channels, freq};
if (sdlAudioDevice) SDL_CloseAudioDevice(sdlAudioDevice);
sdlAudioDevice = SDL_OpenAudioDevice(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, &JA_audioSpec);
if (sdlAudioDevice == 0) std::cout << "Failed to initialize SDL audio!" << '\n';
for (int i = 0; i < JA_MAX_SIMULTANEOUS_CHANNELS; ++i) channels[i].state = JA_CHANNEL_FREE;
for (int i = 0; i < JA_MAX_GROUPS; ++i) JA_soundVolume[i] = 0.5f;
}
inline void JA_Quit() {
if (outgoing_music.stream) {
SDL_DestroyAudioStream(outgoing_music.stream);
outgoing_music.stream = nullptr;
}
if (sdlAudioDevice) SDL_CloseAudioDevice(sdlAudioDevice);
sdlAudioDevice = 0;
}
// --- Music Functions ---
inline JA_Music_t* JA_LoadMusic(const Uint8* buffer, Uint32 length) {
if (!buffer || length == 0) return nullptr;
// Allocem el JA_Music_t primer per aprofitar el seu `std::vector<Uint8>`
// com a propietari del OGG comprimit. stb_vorbis guarda un punter
// persistent al buffer; com que ací no el resize'jem, el .data() és
// estable durant tot el cicle de vida del music.
auto* music = new JA_Music_t();
music->ogg_data.assign(buffer, buffer + length);
int vorbis_error = 0;
music->vorbis = stb_vorbis_open_memory(music->ogg_data.data(),
static_cast<int>(length),
&vorbis_error,
nullptr);
if (!music->vorbis) {
std::cout << "JA_LoadMusic: stb_vorbis_open_memory failed (error " << vorbis_error << ")" << '\n';
delete music;
return nullptr;
}
const stb_vorbis_info info = stb_vorbis_get_info(music->vorbis);
music->spec.channels = info.channels;
music->spec.freq = static_cast<int>(info.sample_rate);
music->spec.format = SDL_AUDIO_S16;
music->state = JA_MUSIC_STOPPED;
return music;
}
// Overload amb filename — els callers l'usen per poder comparar la música
// en curs amb JA_GetMusicFilename() i no rearrancar-la si ja és la mateixa.
inline JA_Music_t* JA_LoadMusic(Uint8* buffer, Uint32 length, const char* filename) {
JA_Music_t* music = JA_LoadMusic(static_cast<const Uint8*>(buffer), length);
if (music && filename) music->filename = filename;
return music;
}
inline JA_Music_t* JA_LoadMusic(const char* filename) {
// Carreguem primer el arxiu en memòria i després el descomprimim.
FILE* f = fopen(filename, "rb");
if (!f) return nullptr;
fseek(f, 0, SEEK_END);
long fsize = ftell(f);
fseek(f, 0, SEEK_SET);
auto* buffer = static_cast<Uint8*>(malloc(fsize + 1));
if (!buffer) {
fclose(f);
return nullptr;
}
if (fread(buffer, fsize, 1, f) != 1) {
fclose(f);
free(buffer);
return nullptr;
}
fclose(f);
JA_Music_t* music = JA_LoadMusic(static_cast<const Uint8*>(buffer), static_cast<Uint32>(fsize));
if (music) {
music->filename = filename;
}
free(buffer);
return music;
}
inline void JA_PlayMusic(JA_Music_t* music, const int loop = -1) {
if (!JA_musicEnabled || !music || !music->vorbis) return;
JA_StopMusic();
current_music = music;
current_music->state = JA_MUSIC_PLAYING;
current_music->times = loop;
// Rebobinem l'stream de vorbis al principi. Cobreix tant play-per-primera-
// vegada com replays/canvis de track que tornen a la mateixa pista.
stb_vorbis_seek_start(current_music->vorbis);
current_music->stream = SDL_CreateAudioStream(&current_music->spec, &JA_audioSpec);
if (!current_music->stream) {
std::cout << "Failed to create audio stream!" << '\n';
current_music->state = JA_MUSIC_STOPPED;
return;
}
SDL_SetAudioStreamGain(current_music->stream, JA_musicVolume);
// Pre-cargem el buffer abans de bindejar per evitar un underrun inicial.
JA_PumpMusic(current_music);
if (!SDL_BindAudioStream(sdlAudioDevice, current_music->stream)) {
std::cout << "[ERROR] SDL_BindAudioStream failed!" << '\n';
}
}
inline const char* JA_GetMusicFilename(const JA_Music_t* music = nullptr) {
if (!music) music = current_music;
if (!music || music->filename.empty()) return nullptr;
return music->filename.c_str();
}
inline void JA_PauseMusic() {
if (!JA_musicEnabled) return;
if (!current_music || current_music->state != JA_MUSIC_PLAYING) return;
current_music->state = JA_MUSIC_PAUSED;
SDL_UnbindAudioStream(current_music->stream);
}
inline void JA_ResumeMusic() {
if (!JA_musicEnabled) return;
if (!current_music || current_music->state != JA_MUSIC_PAUSED) return;
current_music->state = JA_MUSIC_PLAYING;
SDL_BindAudioStream(sdlAudioDevice, current_music->stream);
}
inline void JA_StopMusic() {
// Limpiar outgoing crossfade si existe
if (outgoing_music.stream) {
SDL_DestroyAudioStream(outgoing_music.stream);
outgoing_music.stream = nullptr;
outgoing_music.fade.active = false;
}
incoming_fade.active = false;
if (!current_music || current_music->state == JA_MUSIC_INVALID || current_music->state == JA_MUSIC_STOPPED) return;
current_music->state = JA_MUSIC_STOPPED;
if (current_music->stream) {
SDL_DestroyAudioStream(current_music->stream);
current_music->stream = nullptr;
}
// Deixem el handle de vorbis viu — es tanca en JA_DeleteMusic.
// Rebobinem perquè un futur JA_PlayMusic comence des del principi.
if (current_music->vorbis) {
stb_vorbis_seek_start(current_music->vorbis);
}
}
inline void JA_FadeOutMusic(const int milliseconds) {
if (!JA_musicEnabled) return;
if (!current_music || current_music->state != JA_MUSIC_PLAYING) return;
// Destruir outgoing anterior si existe
if (outgoing_music.stream) {
SDL_DestroyAudioStream(outgoing_music.stream);
outgoing_music.stream = nullptr;
}
// Pre-omplim l'stream amb `milliseconds` de so: un cop robat, ja no
// tindrà accés al vorbis decoder i només podrà drenar el que tinga.
JA_PreFillOutgoing(current_music, milliseconds);
// Robar el stream del current_music al outgoing
outgoing_music.stream = current_music->stream;
outgoing_music.fade = {true, SDL_GetTicks(), milliseconds, JA_musicVolume};
// Dejar current_music sin stream (ya lo tiene outgoing)
current_music->stream = nullptr;
current_music->state = JA_MUSIC_STOPPED;
if (current_music->vorbis) stb_vorbis_seek_start(current_music->vorbis);
incoming_fade.active = false;
}
inline void JA_CrossfadeMusic(JA_Music_t* music, const int crossfade_ms, const int loop) {
if (!JA_musicEnabled || !music || !music->vorbis) return;
// Destruir outgoing anterior si existe (crossfade durante crossfade)
if (outgoing_music.stream) {
SDL_DestroyAudioStream(outgoing_music.stream);
outgoing_music.stream = nullptr;
outgoing_music.fade.active = false;
}
// Robar el stream de la musica actual al outgoing para el fade-out.
// Pre-omplim amb `crossfade_ms` de so perquè no es quede en silenci
// abans d'acabar el fade (l'stream robat ja no pot alimentar-se).
if (current_music && current_music->state == JA_MUSIC_PLAYING && current_music->stream) {
JA_PreFillOutgoing(current_music, crossfade_ms);
outgoing_music.stream = current_music->stream;
outgoing_music.fade = {true, SDL_GetTicks(), crossfade_ms, JA_musicVolume};
current_music->stream = nullptr;
current_music->state = JA_MUSIC_STOPPED;
if (current_music->vorbis) stb_vorbis_seek_start(current_music->vorbis);
}
// Iniciar la nueva pista con gain=0 (el fade-in la sube gradualmente)
current_music = music;
current_music->state = JA_MUSIC_PLAYING;
current_music->times = loop;
stb_vorbis_seek_start(current_music->vorbis);
current_music->stream = SDL_CreateAudioStream(&current_music->spec, &JA_audioSpec);
if (!current_music->stream) {
std::cout << "Failed to create audio stream for crossfade!" << '\n';
current_music->state = JA_MUSIC_STOPPED;
return;
}
SDL_SetAudioStreamGain(current_music->stream, 0.0f);
JA_PumpMusic(current_music); // pre-carrega abans de bindejar
SDL_BindAudioStream(sdlAudioDevice, current_music->stream);
// Configurar fade-in
incoming_fade = {true, SDL_GetTicks(), crossfade_ms, 0.0f};
}
inline JA_Music_state JA_GetMusicState() {
if (!JA_musicEnabled) return JA_MUSIC_DISABLED;
if (!current_music) return JA_MUSIC_INVALID;
return current_music->state;
}
inline void JA_DeleteMusic(JA_Music_t* music) {
if (!music) return;
if (current_music == music) {
JA_StopMusic();
current_music = nullptr;
}
if (music->stream) SDL_DestroyAudioStream(music->stream);
if (music->vorbis) stb_vorbis_close(music->vorbis);
// ogg_data (std::vector) i filename (std::string) s'alliberen sols
// al destructor de JA_Music_t.
delete music;
}
inline float JA_SetMusicVolume(float volume) {
JA_musicVolume = SDL_clamp(volume, 0.0f, 1.0f);
if (current_music && current_music->stream) {
SDL_SetAudioStreamGain(current_music->stream, JA_musicVolume);
}
return JA_musicVolume;
}
inline void JA_SetMusicPosition(float /*value*/) {
// No implementat amb el backend de streaming.
}
inline float JA_GetMusicPosition() {
return 0.0f;
}
inline void JA_EnableMusic(const bool value) {
if (!value && current_music && (current_music->state == JA_MUSIC_PLAYING)) JA_StopMusic();
JA_musicEnabled = value;
}
// --- Sound Functions ---
inline JA_Sound_t* JA_LoadSound(uint8_t* buffer, uint32_t size) {
auto sound = std::make_unique<JA_Sound_t>();
Uint8* raw = nullptr;
if (!SDL_LoadWAV_IO(SDL_IOFromMem(buffer, size), 1, &sound->spec, &raw, &sound->length)) {
std::cout << "Failed to load WAV from memory: " << SDL_GetError() << '\n';
return nullptr;
}
sound->buffer.reset(raw); // adopta el SDL_malloc'd buffer
return sound.release();
}
inline JA_Sound_t* JA_LoadSound(const char* filename) {
auto sound = std::make_unique<JA_Sound_t>();
Uint8* raw = nullptr;
if (!SDL_LoadWAV(filename, &sound->spec, &raw, &sound->length)) {
std::cout << "Failed to load WAV file: " << SDL_GetError() << '\n';
return nullptr;
}
sound->buffer.reset(raw); // adopta el SDL_malloc'd buffer
return sound.release();
}
inline int JA_PlaySound(JA_Sound_t* sound, const int loop = 0, const int group = 0) {
if (!JA_soundEnabled || !sound) return -1;
int channel = 0;
while (channel < JA_MAX_SIMULTANEOUS_CHANNELS && channels[channel].state != JA_CHANNEL_FREE) { channel++; }
if (channel == JA_MAX_SIMULTANEOUS_CHANNELS) {
// No hay canal libre, reemplazamos el primero
channel = 0;
}
return JA_PlaySoundOnChannel(sound, channel, loop, group);
}
inline int JA_PlaySoundOnChannel(JA_Sound_t* sound, const int channel, const int loop, const int group) {
if (!JA_soundEnabled || !sound) return -1;
if (channel < 0 || channel >= JA_MAX_SIMULTANEOUS_CHANNELS) return -1;
JA_StopChannel(channel);
channels[channel].sound = sound;
channels[channel].times = loop;
channels[channel].pos = 0;
channels[channel].group = group;
channels[channel].state = JA_CHANNEL_PLAYING;
channels[channel].stream = SDL_CreateAudioStream(&channels[channel].sound->spec, &JA_audioSpec);
if (!channels[channel].stream) {
std::cout << "Failed to create audio stream for sound!" << '\n';
channels[channel].state = JA_CHANNEL_FREE;
return -1;
}
SDL_PutAudioStreamData(channels[channel].stream, channels[channel].sound->buffer.get(), channels[channel].sound->length);
SDL_SetAudioStreamGain(channels[channel].stream, JA_soundVolume[group]);
SDL_BindAudioStream(sdlAudioDevice, channels[channel].stream);
return channel;
}
inline void JA_DeleteSound(JA_Sound_t* sound) {
if (!sound) return;
for (int i = 0; i < JA_MAX_SIMULTANEOUS_CHANNELS; i++) {
if (channels[i].sound == sound) JA_StopChannel(i);
}
// buffer es destrueix automàticament via RAII (SDLFreeDeleter).
delete sound;
}
inline void JA_PauseChannel(const int channel) {
if (!JA_soundEnabled) return;
if (channel == -1) {
for (int i = 0; i < JA_MAX_SIMULTANEOUS_CHANNELS; i++)
if (channels[i].state == JA_CHANNEL_PLAYING) {
channels[i].state = JA_CHANNEL_PAUSED;
SDL_UnbindAudioStream(channels[i].stream);
}
} else if (channel >= 0 && channel < JA_MAX_SIMULTANEOUS_CHANNELS) {
if (channels[channel].state == JA_CHANNEL_PLAYING) {
channels[channel].state = JA_CHANNEL_PAUSED;
SDL_UnbindAudioStream(channels[channel].stream);
}
}
}
inline void JA_ResumeChannel(const int channel) {
if (!JA_soundEnabled) return;
if (channel == -1) {
for (int i = 0; i < JA_MAX_SIMULTANEOUS_CHANNELS; i++)
if (channels[i].state == JA_CHANNEL_PAUSED) {
channels[i].state = JA_CHANNEL_PLAYING;
SDL_BindAudioStream(sdlAudioDevice, channels[i].stream);
}
} else if (channel >= 0 && channel < JA_MAX_SIMULTANEOUS_CHANNELS) {
if (channels[channel].state == JA_CHANNEL_PAUSED) {
channels[channel].state = JA_CHANNEL_PLAYING;
SDL_BindAudioStream(sdlAudioDevice, channels[channel].stream);
}
}
}
inline void JA_StopChannel(const int channel) {
if (channel == -1) {
for (int i = 0; i < JA_MAX_SIMULTANEOUS_CHANNELS; i++) {
if (channels[i].state != JA_CHANNEL_FREE) {
if (channels[i].stream) SDL_DestroyAudioStream(channels[i].stream);
channels[i].stream = nullptr;
channels[i].state = JA_CHANNEL_FREE;
channels[i].pos = 0;
channels[i].sound = nullptr;
}
}
} else if (channel >= 0 && channel < JA_MAX_SIMULTANEOUS_CHANNELS) {
if (channels[channel].state != JA_CHANNEL_FREE) {
if (channels[channel].stream) SDL_DestroyAudioStream(channels[channel].stream);
channels[channel].stream = nullptr;
channels[channel].state = JA_CHANNEL_FREE;
channels[channel].pos = 0;
channels[channel].sound = nullptr;
}
}
}
inline JA_Channel_state JA_GetChannelState(const int channel) {
if (!JA_soundEnabled) return JA_SOUND_DISABLED;
if (channel < 0 || channel >= JA_MAX_SIMULTANEOUS_CHANNELS) return JA_CHANNEL_INVALID;
return channels[channel].state;
}
inline float JA_SetSoundVolume(float volume, const int group = -1) {
const float v = SDL_clamp(volume, 0.0f, 1.0f);
if (group == -1) {
for (int i = 0; i < JA_MAX_GROUPS; ++i) {
JA_soundVolume[i] = v;
}
} else if (group >= 0 && group < JA_MAX_GROUPS) {
JA_soundVolume[group] = v;
} else {
return v;
}
// Aplicar volum als canals actius.
for (int i = 0; i < JA_MAX_SIMULTANEOUS_CHANNELS; i++) {
if ((channels[i].state == JA_CHANNEL_PLAYING) || (channels[i].state == JA_CHANNEL_PAUSED)) {
if (group == -1 || channels[i].group == group) {
if (channels[i].stream) {
SDL_SetAudioStreamGain(channels[i].stream, JA_soundVolume[channels[i].group]);
}
}
}
}
return v;
}
inline void JA_EnableSound(const bool value) {
if (!value) {
JA_StopChannel(-1);
}
JA_soundEnabled = value;
}
inline float JA_SetVolume(float volume) {
float v = JA_SetMusicVolume(volume);
JA_SetSoundVolume(v, -1);
return v;
}

View File

@@ -1,16 +1,73 @@
#include "core/input/gamepad.hpp" #include "core/input/gamepad.hpp"
#include <cstdio> #include <cstdio>
#include <string>
#include "core/input/key_config.hpp"
#include "core/jail/jinput.hpp" #include "core/jail/jinput.hpp"
#include "core/locale/locale.hpp"
#include "core/rendering/menu.hpp" #include "core/rendering/menu.hpp"
#include "game/options.hpp" #include "core/rendering/overlay.hpp"
namespace Gamepad { namespace Gamepad {
static SDL_Gamepad* pad_ = nullptr; static SDL_Gamepad* pad_ = nullptr;
static SDL_JoystickID pad_id_ = 0; static SDL_JoystickID pad_id_ = 0;
// Emscripten-only: SDL 3.4+ ja no casa el GUID dels mandos web (el gamepad.id
// de Chrome/Android no porta Vendor/Product, el parser extreu valors
// escombraries, el GUID no està a gamecontrollerdb i el gamepad queda
// obert amb un mapping incorrecte). Com el W3C Gamepad API garanteix
// layout estàndard quan mapping=="standard", injectem un mapping SDL
// amb eixe layout per al GUID del joystick abans d'obrir-lo com gamepad.
// Fora d'Emscripten és un no-op.
static void installWebStandardMapping(SDL_JoystickID jid) {
#ifdef __EMSCRIPTEN__
SDL_GUID guid = SDL_GetJoystickGUIDForID(jid);
char guidStr[33];
SDL_GUIDToString(guid, guidStr, sizeof(guidStr));
const char* name = SDL_GetJoystickNameForID(jid);
if (!name || !*name) 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",
guidStr,
name);
SDL_AddGamepadMapping(mapping);
#else
(void)jid;
#endif
}
// Recorta el nom visible del mando: trim des del primer '(' o '['
// (per a evitar coses com "Retroid Controller (vendor: 1001) ..."),
// elimina espais finals i talla a 25 caràcters.
static std::string prettyName(const char* raw) {
std::string name = (raw && *raw) ? raw : "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.size() > 25) {
name.resize(25);
}
if (name.empty()) name = "Gamepad";
return name;
}
// Dead-zone del stick esquerre (rang Sint16: -32768..32767) // Dead-zone del stick esquerre (rang Sint16: -32768..32767)
static constexpr Sint16 STICK_DEADZONE = 12000; static constexpr Sint16 STICK_DEADZONE = 12000;
@@ -19,22 +76,41 @@ namespace Gamepad {
static bool prev_down_ = false; static bool prev_down_ = false;
static bool prev_left_ = false; static bool prev_left_ = false;
static bool prev_right_ = false; static bool prev_right_ = false;
static bool prev_a_ = false; static bool prev_south_ = false;
static bool prev_b_ = false; static bool prev_east_ = false;
static bool prev_west_ = false;
static bool prev_north_ = false;
static bool prev_start_ = false; static bool prev_start_ = false;
static bool prev_back_ = false; static bool prev_back_ = false;
static void notify(const std::string& name, const char* status_key) {
std::string msg = name.empty() ? "Gamepad" : name;
msg += ' ';
msg += Locale::get(status_key);
Overlay::showNotification(msg.c_str(), 2.5F);
}
static void notifyConnected(const std::string& name) { notify(name, "notifications.gamepad_connected"); }
static void notifyDisconnected(const std::string& name) { notify(name, "notifications.gamepad_disconnected"); }
// Obri el primer joystick disponible que siga reconegut com a gamepad
// (o que ho esdevinga després d'injectar el mapping web estàndard).
static void openFirstGamepad() { static void openFirstGamepad() {
int count = 0; int count = 0;
SDL_JoystickID* ids = SDL_GetGamepads(&count); SDL_JoystickID* ids = SDL_GetJoysticks(&count);
if (ids && count > 0) { if (ids) {
pad_ = SDL_OpenGamepad(ids[0]); for (int i = 0; i < count; ++i) {
installWebStandardMapping(ids[i]);
if (!SDL_IsGamepad(ids[i])) continue;
pad_ = SDL_OpenGamepad(ids[i]);
if (pad_) { if (pad_) {
pad_id_ = ids[0]; pad_id_ = ids[i];
SDL_Log("Gamepad connectat: %s", SDL_GetGamepadName(pad_)); SDL_Log("Gamepad connectat: %s", SDL_GetGamepadName(pad_));
break;
} }
} }
if (ids) SDL_free(ids); SDL_free(ids);
}
} }
void init() { void init() {
@@ -65,17 +141,26 @@ namespace Gamepad {
} }
void handleEvent(const SDL_Event& event) { void handleEvent(const SDL_Event& event) {
if (event.type == SDL_EVENT_GAMEPAD_ADDED) { // A Emscripten els dispositius web entren com a JOYSTICK_ADDED (no
// GAMEPAD_ADDED) perquè SDL no reconeix el GUID. Escoltem els dos i
// injectem el mapping estàndard abans d'obrir el mando.
if (event.type == SDL_EVENT_GAMEPAD_ADDED || event.type == SDL_EVENT_JOYSTICK_ADDED) {
if (!pad_) { if (!pad_) {
pad_ = SDL_OpenGamepad(event.gdevice.which); SDL_JoystickID jid = event.jdevice.which;
installWebStandardMapping(jid);
if (!SDL_IsGamepad(jid)) return;
pad_ = SDL_OpenGamepad(jid);
if (pad_) { if (pad_) {
pad_id_ = event.gdevice.which; pad_id_ = jid;
SDL_Log("Gamepad connectat: %s", SDL_GetGamepadName(pad_)); std::string name = prettyName(SDL_GetGamepadName(pad_));
SDL_Log("Gamepad connectat: %s", name.c_str());
notifyConnected(name);
} }
} }
} else if (event.type == SDL_EVENT_GAMEPAD_REMOVED) { } else if (event.type == SDL_EVENT_GAMEPAD_REMOVED || event.type == SDL_EVENT_JOYSTICK_REMOVED) {
if (pad_ && event.gdevice.which == pad_id_) { if (pad_ && event.jdevice.which == pad_id_) {
SDL_Log("Gamepad desconnectat"); std::string saved_name = prettyName(SDL_GetGamepadName(pad_));
SDL_Log("Gamepad desconnectat: %s", saved_name.c_str());
SDL_CloseGamepad(pad_); SDL_CloseGamepad(pad_);
pad_ = nullptr; pad_ = nullptr;
pad_id_ = 0; pad_id_ = 0;
@@ -84,6 +169,7 @@ namespace Gamepad {
JI_SetVirtualKey(SDL_SCANCODE_DOWN, JI_VSRC_GAMEPAD, false); JI_SetVirtualKey(SDL_SCANCODE_DOWN, JI_VSRC_GAMEPAD, false);
JI_SetVirtualKey(SDL_SCANCODE_LEFT, JI_VSRC_GAMEPAD, false); JI_SetVirtualKey(SDL_SCANCODE_LEFT, JI_VSRC_GAMEPAD, false);
JI_SetVirtualKey(SDL_SCANCODE_RIGHT, JI_VSRC_GAMEPAD, false); JI_SetVirtualKey(SDL_SCANCODE_RIGHT, JI_VSRC_GAMEPAD, false);
notifyDisconnected(saved_name);
} }
} }
} }
@@ -125,16 +211,18 @@ namespace Gamepad {
bool lt = dlt || slt; bool lt = dlt || slt;
bool rt = drt || srt; bool rt = drt || srt;
// Botons // Botons frontals (layout SDL: SOUTH=A/Cross, EAST=B/Circle, WEST=X/Square, NORTH=Y/Triangle)
bool a = SDL_GetGamepadButton(pad_, SDL_GAMEPAD_BUTTON_SOUTH); // A/Cross bool south = SDL_GetGamepadButton(pad_, SDL_GAMEPAD_BUTTON_SOUTH);
bool b = SDL_GetGamepadButton(pad_, SDL_GAMEPAD_BUTTON_EAST); // B/Circle bool east = SDL_GetGamepadButton(pad_, SDL_GAMEPAD_BUTTON_EAST);
bool west = SDL_GetGamepadButton(pad_, SDL_GAMEPAD_BUTTON_WEST);
bool north = SDL_GetGamepadButton(pad_, SDL_GAMEPAD_BUTTON_NORTH);
bool start = SDL_GetGamepadButton(pad_, SDL_GAMEPAD_BUTTON_START); bool start = SDL_GetGamepadButton(pad_, SDL_GAMEPAD_BUTTON_START);
bool back = SDL_GetGamepadButton(pad_, SDL_GAMEPAD_BUTTON_BACK); bool back = SDL_GetGamepadButton(pad_, SDL_GAMEPAD_BUTTON_BACK);
// Start → obre/tanca menú (flanc) // Select (Back) → obre/tanca menú de servei (flanc)
if (start && !prev_start_) pushKey(Options::keys_gui.menu_toggle); if (back && !prev_back_) pushKey(KeyConfig::scancode("menu_toggle"));
// Back → ESC (flanc) // Start → pausa (flanc)
if (back && !prev_back_) pushKey(SDL_SCANCODE_ESCAPE); if (start && !prev_start_) pushKey(KeyConfig::scancode("pause_toggle"));
if (Menu::isOpen()) { if (Menu::isOpen()) {
// Navegació del menú per flanc // Navegació del menú per flanc
@@ -142,8 +230,9 @@ namespace Gamepad {
if (dn && !prev_down_) pushKey(SDL_SCANCODE_DOWN); if (dn && !prev_down_) pushKey(SDL_SCANCODE_DOWN);
if (lt && !prev_left_) pushKey(SDL_SCANCODE_LEFT); if (lt && !prev_left_) pushKey(SDL_SCANCODE_LEFT);
if (rt && !prev_right_) pushKey(SDL_SCANCODE_RIGHT); if (rt && !prev_right_) pushKey(SDL_SCANCODE_RIGHT);
if (a && !prev_a_) pushKey(SDL_SCANCODE_RETURN); // EAST accepta, SOUTH cancela / endarrere
if (b && !prev_b_) pushKey(SDL_SCANCODE_BACKSPACE); if (east && !prev_east_) pushKey(SDL_SCANCODE_RETURN);
if (south && !prev_south_) pushKey(SDL_SCANCODE_BACKSPACE);
// Assegura que el joc no rep tecles de moviment mentre el menú està obert // Assegura que el joc no rep tecles de moviment mentre el menú està obert
JI_SetVirtualKey(SDL_SCANCODE_UP, JI_VSRC_GAMEPAD, false); JI_SetVirtualKey(SDL_SCANCODE_UP, JI_VSRC_GAMEPAD, false);
@@ -156,16 +245,21 @@ namespace Gamepad {
JI_SetVirtualKey(SDL_SCANCODE_DOWN, JI_VSRC_GAMEPAD, dn); JI_SetVirtualKey(SDL_SCANCODE_DOWN, JI_VSRC_GAMEPAD, dn);
JI_SetVirtualKey(SDL_SCANCODE_LEFT, JI_VSRC_GAMEPAD, lt); JI_SetVirtualKey(SDL_SCANCODE_LEFT, JI_VSRC_GAMEPAD, lt);
JI_SetVirtualKey(SDL_SCANCODE_RIGHT, JI_VSRC_GAMEPAD, rt); JI_SetVirtualKey(SDL_SCANCODE_RIGHT, JI_VSRC_GAMEPAD, rt);
// Botó A al joc: emet Enter per avançar seqüències (JI_AnyKey) // Qualsevol dels 4 botons frontals avança escenes (JI_AnyKey via Enter sintètic)
if (a && !prev_a_) pushKey(SDL_SCANCODE_RETURN); if ((south && !prev_south_) || (east && !prev_east_) ||
(west && !prev_west_) || (north && !prev_north_)) {
pushKey(SDL_SCANCODE_RETURN);
}
} }
prev_up_ = up; prev_up_ = up;
prev_down_ = dn; prev_down_ = dn;
prev_left_ = lt; prev_left_ = lt;
prev_right_ = rt; prev_right_ = rt;
prev_a_ = a; prev_south_ = south;
prev_b_ = b; prev_east_ = east;
prev_west_ = west;
prev_north_ = north;
prev_start_ = start; prev_start_ = start;
prev_back_ = back; prev_back_ = back;
} }

View File

@@ -3,6 +3,7 @@
#include <cstdio> #include <cstdio>
#include <string> #include <string>
#include "core/input/key_config.hpp"
#include "core/jail/jinput.hpp" #include "core/jail/jinput.hpp"
#include "core/locale/locale.hpp" #include "core/locale/locale.hpp"
#include "core/rendering/overlay.hpp" #include "core/rendering/overlay.hpp"
@@ -19,14 +20,14 @@ namespace GlobalInputs {
static bool ss_prev = false; static bool ss_prev = false;
static bool next_shader_prev = false; static bool next_shader_prev = false;
static bool next_preset_prev = false; static bool next_preset_prev = false;
static bool stretch_filter_prev = false; static bool texture_filter_prev = false;
static bool render_info_prev = false; static bool render_info_prev = false;
auto handle() -> bool { auto handle() -> bool {
bool consumed = false; bool consumed = false;
// F1 — Reduir zoom // F1 — Reduir zoom
bool dec_zoom = JI_KeyPressed(Options::keys_gui.dec_zoom); bool dec_zoom = JI_KeyPressed(KeyConfig::scancode("dec_zoom"));
if (dec_zoom && !dec_zoom_prev) { if (dec_zoom && !dec_zoom_prev) {
Screen::get()->decZoom(); Screen::get()->decZoom();
char msg[32]; char msg[32];
@@ -37,7 +38,7 @@ namespace GlobalInputs {
dec_zoom_prev = dec_zoom; dec_zoom_prev = dec_zoom;
// F2 — Augmentar zoom // F2 — Augmentar zoom
bool inc_zoom = JI_KeyPressed(Options::keys_gui.inc_zoom); bool inc_zoom = JI_KeyPressed(KeyConfig::scancode("inc_zoom"));
if (inc_zoom && !inc_zoom_prev) { if (inc_zoom && !inc_zoom_prev) {
Screen::get()->incZoom(); Screen::get()->incZoom();
char msg[32]; char msg[32];
@@ -48,7 +49,7 @@ namespace GlobalInputs {
inc_zoom_prev = inc_zoom; inc_zoom_prev = inc_zoom;
// F3 — Toggle pantalla completa // F3 — Toggle pantalla completa
bool fullscreen = JI_KeyPressed(Options::keys_gui.fullscreen); bool fullscreen = JI_KeyPressed(KeyConfig::scancode("fullscreen"));
if (fullscreen && !fullscreen_prev) { if (fullscreen && !fullscreen_prev) {
Screen::get()->toggleFullscreen(); Screen::get()->toggleFullscreen();
Overlay::showNotification(Screen::get()->isFullscreen() ? Locale::get("notifications.fullscreen") : Locale::get("notifications.windowed")); Overlay::showNotification(Screen::get()->isFullscreen() ? Locale::get("notifications.fullscreen") : Locale::get("notifications.windowed"));
@@ -57,7 +58,7 @@ namespace GlobalInputs {
fullscreen_prev = fullscreen; fullscreen_prev = fullscreen;
// F4 — Toggle shaders // F4 — Toggle shaders
bool shader = JI_KeyPressed(Options::keys_gui.toggle_shader); bool shader = JI_KeyPressed(KeyConfig::scancode("toggle_shader"));
if (shader && !shader_prev) { if (shader && !shader_prev) {
Screen::get()->toggleShaders(); Screen::get()->toggleShaders();
Overlay::showNotification(Options::video.shader_enabled ? Locale::get("notifications.shader_on") : Locale::get("notifications.shader_off")); Overlay::showNotification(Options::video.shader_enabled ? Locale::get("notifications.shader_on") : Locale::get("notifications.shader_off"));
@@ -66,7 +67,7 @@ namespace GlobalInputs {
shader_prev = shader; shader_prev = shader;
// F5 — Toggle aspect ratio 4:3 // F5 — Toggle aspect ratio 4:3
bool aspect = JI_KeyPressed(Options::keys_gui.toggle_aspect_ratio); bool aspect = JI_KeyPressed(KeyConfig::scancode("toggle_aspect_ratio"));
if (aspect && !aspect_prev) { if (aspect && !aspect_prev) {
Screen::get()->toggleAspectRatio(); Screen::get()->toggleAspectRatio();
Overlay::showNotification(Options::video.aspect_ratio_4_3 ? Locale::get("notifications.aspect_43") : Locale::get("notifications.aspect_square")); Overlay::showNotification(Options::video.aspect_ratio_4_3 ? Locale::get("notifications.aspect_43") : Locale::get("notifications.aspect_square"));
@@ -75,47 +76,52 @@ namespace GlobalInputs {
aspect_prev = aspect; aspect_prev = aspect;
// F6 — Toggle supersampling // F6 — Toggle supersampling
bool ss = JI_KeyPressed(Options::keys_gui.toggle_supersampling); bool ss = JI_KeyPressed(KeyConfig::scancode("toggle_supersampling"));
if (ss && !ss_prev) { if (ss && !ss_prev) {
Screen::get()->toggleSupersampling(); if (Screen::get()->toggleSupersampling()) {
Overlay::showNotification(Options::video.supersampling ? Locale::get("notifications.ss_on") : Locale::get("notifications.ss_off")); Overlay::showNotification(Options::video.supersampling ? Locale::get("notifications.ss_on") : Locale::get("notifications.ss_off"));
} }
}
if (ss) consumed = true; if (ss) consumed = true;
ss_prev = ss; ss_prev = ss;
// F7 — Canviar tipus de shader (PostFX ↔ CrtPi) // F7 — Canviar tipus de shader (PostFX ↔ CrtPi)
bool next_shader = JI_KeyPressed(Options::keys_gui.next_shader); bool next_shader = JI_KeyPressed(KeyConfig::scancode("next_shader"));
if (next_shader && !next_shader_prev) { if (next_shader && !next_shader_prev) {
Screen::get()->nextShaderType(); if (Screen::get()->nextShaderType()) {
char msg[64]; char msg[64];
snprintf(msg, sizeof(msg), "%s: %s", Screen::get()->getActiveShaderName(), Screen::get()->getCurrentPresetName()); snprintf(msg, sizeof(msg), "%s: %s", Screen::get()->getActiveShaderName(), Screen::get()->getCurrentPresetName());
Overlay::showNotification(msg); Overlay::showNotification(msg);
} }
}
if (next_shader) consumed = true; if (next_shader) consumed = true;
next_shader_prev = next_shader; next_shader_prev = next_shader;
// F8 — Pròxim preset del shader actiu // F8 — Pròxim preset del shader actiu
bool next_preset = JI_KeyPressed(Options::keys_gui.next_shader_preset); bool next_preset = JI_KeyPressed(KeyConfig::scancode("next_shader_preset"));
if (next_preset && !next_preset_prev) { if (next_preset && !next_preset_prev) {
Screen::get()->nextPreset(); if (Screen::get()->nextPreset()) {
char msg[64]; char msg[64];
snprintf(msg, sizeof(msg), Locale::get("notifications.preset_fmt"), Screen::get()->getCurrentPresetName()); snprintf(msg, sizeof(msg), Locale::get("notifications.preset_fmt"), Screen::get()->getCurrentPresetName());
Overlay::showNotification(msg); Overlay::showNotification(msg);
} }
}
if (next_preset) consumed = true; if (next_preset) consumed = true;
next_preset_prev = next_preset; next_preset_prev = next_preset;
// F9 — Toggle filtre d'estirament 4:3 (NEAREST ↔ LINEAR) // F9 — Cicla filtre de textura (NEAREST ↔ LINEAR), sempre aplicat
bool stretch_filter = JI_KeyPressed(Options::keys_gui.toggle_stretch_filter); bool texture_filter = JI_KeyPressed(KeyConfig::scancode("cycle_texture_filter"));
if (stretch_filter && !stretch_filter_prev) { if (texture_filter && !texture_filter_prev) {
Screen::get()->toggleStretchFilter(); Screen::get()->cycleTextureFilter(+1);
Overlay::showNotification(Options::video.stretch_filter_linear ? Locale::get("notifications.filter_linear") : Locale::get("notifications.filter_nearest")); Overlay::showNotification(Options::video.texture_filter == Options::TextureFilter::LINEAR
? Locale::get("notifications.filter_linear")
: Locale::get("notifications.filter_nearest"));
} }
if (stretch_filter) consumed = true; if (texture_filter) consumed = true;
stretch_filter_prev = stretch_filter; texture_filter_prev = texture_filter;
// F10 — Toggle render info (FPS, driver, shader) // F10 — Toggle render info (FPS, driver, shader)
bool render_info = JI_KeyPressed(Options::keys_gui.toggle_render_info); bool render_info = JI_KeyPressed(KeyConfig::scancode("toggle_render_info"));
if (render_info && !render_info_prev) { if (render_info && !render_info_prev) {
Overlay::toggleRenderInfo(); Overlay::toggleRenderInfo();
} }

View File

@@ -0,0 +1,182 @@
#include "core/input/key_config.hpp"
#include <fstream>
#include <iostream>
#include <utility>
#include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp"
namespace KeyConfig {
namespace {
std::vector<KeyEntry> entries_;
std::unordered_map<std::string, size_t> index_;
std::string overrides_path_;
auto findIndex(const std::string& id) -> size_t {
auto it = index_.find(id);
if (it == index_.end()) return SIZE_MAX;
return it->second;
}
void loadDefaults(const std::string& defaults_resource_path) {
auto buf = ResourceHelper::loadFile(defaults_resource_path);
if (buf.empty()) {
std::cerr << "KeyConfig: no s'ha pogut llegir " << defaults_resource_path << '\n';
return;
}
std::string content(buf.begin(), buf.end());
try {
auto yaml = fkyaml::node::deserialize(content);
if (!yaml.contains("keys")) return;
for (const auto& node : yaml["keys"]) {
KeyEntry entry;
entry.id = node["id"].get_value<std::string>();
entry.code = node["code"].get_value<std::string>();
if (node.contains("desc")) {
entry.desc = node["desc"].get_value<std::string>();
}
SDL_Scancode sc = SDL_GetScancodeFromName(entry.code.c_str());
if (sc == SDL_SCANCODE_UNKNOWN) {
std::cerr << "KeyConfig: scancode desconegut '" << entry.code
<< "' per '" << entry.id << "'\n";
}
entry.scancode = sc;
entry.default_scancode = sc;
index_[entry.id] = entries_.size();
entries_.push_back(std::move(entry));
}
std::cout << "KeyConfig: " << entries_.size() << " tecles carregades de "
<< defaults_resource_path << '\n';
} catch (const fkyaml::exception& e) {
std::cerr << "KeyConfig: error parsejant YAML: " << e.what() << '\n';
}
}
void applyOverrides(const std::string& disk_path) {
std::ifstream file(disk_path);
if (!file.good()) return;
std::string content((std::istreambuf_iterator<char>(file)),
std::istreambuf_iterator<char>());
file.close();
try {
auto yaml = fkyaml::node::deserialize(content);
if (!yaml.contains("overrides")) return;
int applied = 0;
for (const auto& kv : yaml["overrides"].as_map()) {
auto id = kv.first.get_value<std::string>();
auto code = kv.second.get_value<std::string>();
auto idx = findIndex(id);
if (idx == SIZE_MAX) {
std::cerr << "KeyConfig: override per id desconegut '" << id << "'\n";
continue;
}
SDL_Scancode sc = SDL_GetScancodeFromName(code.c_str());
if (sc == SDL_SCANCODE_UNKNOWN) {
std::cerr << "KeyConfig: override amb scancode invàlid '" << code
<< "' per '" << id << "'\n";
continue;
}
entries_[idx].scancode = sc;
entries_[idx].code = code;
applied++;
}
if (applied > 0) {
std::cout << "KeyConfig: aplicats " << applied
<< " overrides de " << disk_path << '\n';
}
} catch (const fkyaml::exception& e) {
std::cerr << "KeyConfig: error parsejant overrides: " << e.what() << '\n';
}
}
} // namespace
void init(const std::string& defaults_resource_path,
const std::string& user_overrides_disk_path) {
entries_.clear();
index_.clear();
overrides_path_ = user_overrides_disk_path;
loadDefaults(defaults_resource_path);
if (!overrides_path_.empty()) {
applyOverrides(overrides_path_);
}
}
void destroy() {
entries_.clear();
index_.clear();
overrides_path_.clear();
}
auto scancode(const std::string& id) -> SDL_Scancode {
auto idx = findIndex(id);
if (idx == SIZE_MAX) return SDL_SCANCODE_UNKNOWN;
return entries_[idx].scancode;
}
auto scancodePtr(const std::string& id) -> SDL_Scancode* {
auto idx = findIndex(id);
if (idx == SIZE_MAX) return nullptr;
return &entries_[idx].scancode;
}
void setScancode(const std::string& id, SDL_Scancode sc) {
auto idx = findIndex(id);
if (idx == SIZE_MAX) return;
entries_[idx].scancode = sc;
const char* name = SDL_GetScancodeName(sc);
entries_[idx].code = (name != nullptr) ? name : "";
}
auto isGuiKey(SDL_Scancode sc) -> bool {
if (sc == SDL_SCANCODE_UNKNOWN) return false;
for (const auto& e : entries_) {
if (e.scancode == sc) return true;
}
return false;
}
auto entries() -> const std::vector<KeyEntry>& {
return entries_;
}
auto saveOverrides() -> bool {
if (overrides_path_.empty()) return false;
// Recull només les entrades remapeades.
std::vector<const KeyEntry*> changed;
for (const auto& e : entries_) {
if (e.scancode != e.default_scancode) changed.push_back(&e);
}
std::ofstream file(overrides_path_);
if (!file.is_open()) {
std::cerr << "KeyConfig: no es pot escriure " << overrides_path_ << '\n';
return false;
}
file << "# AEE - Overrides de tecles d'UI\n";
file << "# Auto-generat. Només llista les tecles modificades respecte\n";
file << "# els valors per defecte de data/input/keys.yaml.\n";
file << "\n";
if (changed.empty()) {
file << "overrides: {}\n";
} else {
file << "overrides:\n";
for (const auto* e : changed) {
file << " " << e->id << ": \"" << e->code << "\"\n";
}
}
return true;
}
} // namespace KeyConfig

View File

@@ -0,0 +1,52 @@
#pragma once
#include <SDL3/SDL.h>
#include <string>
#include <unordered_map>
#include <vector>
// KeyConfig: font única de veritat per a les tecles d'UI/sistema.
//
// Llegeix els valors per defecte des de `data/input/keys.yaml` (recurs read-only)
// i opcionalment aplica overrides des d'un fitxer de l'usuari (per a remapejos
// fets des del menú de servei). Els callers consulten per `id` (ex. "menu_toggle").
//
// Les tecles de moviment del jugador NO viuen ací — es queden a Options::keys_game.
struct KeyEntry {
std::string id;
std::string code; // nom SDL del scancode tal com apareix al YAML
std::string desc;
SDL_Scancode scancode{SDL_SCANCODE_UNKNOWN};
SDL_Scancode default_scancode{SDL_SCANCODE_UNKNOWN};
};
namespace KeyConfig {
// Inicialitza KeyConfig llegint defaults des d'un recurs (via ResourceHelper)
// i opcionalment sobreposant overrides des d'un fitxer de disc.
void init(const std::string& defaults_resource_path,
const std::string& user_overrides_disk_path);
void destroy();
// Consulta el scancode actual associat a un id. Torna SDL_SCANCODE_UNKNOWN si no existix.
[[nodiscard]] auto scancode(const std::string& id) -> SDL_Scancode;
// Punter estable al scancode d'un id — útil per a Menu::ItemKind::KeyBind.
// Torna nullptr si l'id no existix.
[[nodiscard]] auto scancodePtr(const std::string& id) -> SDL_Scancode*;
// Estableix el scancode d'un id. No persistix per si sol — cal cridar saveOverrides().
void setScancode(const std::string& id, SDL_Scancode sc);
// True si el scancode coincidix amb alguna tecla d'UI registrada.
// Usat pel Director per a evitar que tecles d'UI activen `key_pressed_` al joc.
[[nodiscard]] auto isGuiKey(SDL_Scancode sc) -> bool;
// Llistat complet de les entrades (per a HELP / debug / iteració).
[[nodiscard]] auto entries() -> const std::vector<KeyEntry>&;
// Persistix al fitxer d'overrides les entrades que difereixen del default.
// Si no s'ha proporcionat user_overrides_disk_path al init, és no-op.
auto saveOverrides() -> bool;
} // namespace KeyConfig

View File

@@ -1,450 +0,0 @@
#ifndef JA_USESDLMIXER
#include "core/jail/jail_audio.hpp"
#include <SDL3/SDL.h>
#include <stdio.h>
#include "external/stb_vorbis.h"
#define JA_MAX_SIMULTANEOUS_CHANNELS 5
struct JA_Sound_t {
SDL_AudioSpec spec{SDL_AUDIO_S16, 2, 48000};
Uint32 length{0};
Uint8* buffer{NULL};
};
struct JA_Channel_t {
JA_Sound_t* sound{nullptr};
int pos{0};
int times{0};
SDL_AudioStream* stream{nullptr};
JA_Channel_state state{JA_CHANNEL_FREE};
};
struct JA_Music_t {
SDL_AudioSpec spec{SDL_AUDIO_S16, 2, 48000};
Uint32 length{0};
Uint8* buffer{nullptr};
char* filename{nullptr};
int pos{0};
int times{0};
SDL_AudioStream* stream{nullptr};
JA_Music_state state{JA_MUSIC_INVALID};
};
JA_Music_t* current_music{nullptr};
JA_Channel_t channels[JA_MAX_SIMULTANEOUS_CHANNELS];
SDL_AudioSpec JA_audioSpec{SDL_AUDIO_S16, 2, 48000};
float JA_musicVolume{1.0f};
float JA_soundVolume{0.5f};
bool JA_musicEnabled{true};
bool JA_soundEnabled{true};
SDL_AudioDeviceID sdlAudioDevice{0};
SDL_TimerID JA_timerID{0};
bool fading = false;
int fade_start_time;
int fade_duration;
int fade_initial_volume;
/*
void audioCallback(void * userdata, uint8_t * stream, int len) {
SDL_memset(stream, 0, len);
if (current_music != NULL && current_music->state == JA_MUSIC_PLAYING) {
const int size = SDL_min(len, current_music->samples*2-current_music->pos);
SDL_MixAudioFormat(stream, (Uint8*)(current_music->output+current_music->pos), AUDIO_S16, size, JA_musicVolume);
current_music->pos += size/2;
if (size < len) {
if (current_music->times != 0) {
SDL_MixAudioFormat(stream+size, (Uint8*)current_music->output, AUDIO_S16, len-size, JA_musicVolume);
current_music->pos = (len-size)/2;
if (current_music->times > 0) current_music->times--;
} else {
current_music->pos = 0;
current_music->state = JA_MUSIC_STOPPED;
}
}
}
// Mixar els channels mi amol
for (int i = 0; i < JA_MAX_SIMULTANEOUS_CHANNELS; i++) {
if (channels[i].state == JA_CHANNEL_PLAYING) {
const int size = SDL_min(len, channels[i].sound->length - channels[i].pos);
SDL_MixAudioFormat(stream, channels[i].sound->buffer + channels[i].pos, AUDIO_S16, size, JA_soundVolume);
channels[i].pos += size;
if (size < len) {
if (channels[i].times != 0) {
SDL_MixAudioFormat(stream + size, channels[i].sound->buffer, AUDIO_S16, len-size, JA_soundVolume);
channels[i].pos = len-size;
if (channels[i].times > 0) channels[i].times--;
} else {
JA_StopChannel(i);
}
}
}
}
}
*/
Uint32 JA_UpdateCallback(void* userdata, SDL_TimerID timerID, Uint32 interval) {
if (JA_musicEnabled && current_music && current_music->state == JA_MUSIC_PLAYING) {
if (fading) {
int time = SDL_GetTicks();
if (time > (fade_start_time + fade_duration)) {
fading = false;
JA_StopMusic();
return 30;
} else {
const int time_passed = time - fade_start_time;
const float percent = (float)time_passed / (float)fade_duration;
SDL_SetAudioStreamGain(current_music->stream, JA_musicVolume * (1.0 - percent));
}
}
if (current_music->times != 0) {
if (SDL_GetAudioStreamAvailable(current_music->stream) < int(current_music->length / 2)) {
SDL_PutAudioStreamData(current_music->stream, current_music->buffer, current_music->length);
}
if (current_music->times > 0) current_music->times--;
} else {
if (SDL_GetAudioStreamAvailable(current_music->stream) == 0) JA_StopMusic();
}
}
if (JA_soundEnabled) {
for (int i = 0; i < JA_MAX_SIMULTANEOUS_CHANNELS; ++i)
if (channels[i].state == JA_CHANNEL_PLAYING) {
if (channels[i].times != 0) {
if (SDL_GetAudioStreamAvailable(channels[i].stream) < int(channels[i].sound->length / 2)) {
SDL_PutAudioStreamData(channels[i].stream, channels[i].sound->buffer, channels[i].sound->length);
if (channels[i].times > 0) channels[i].times--;
}
} else {
if (SDL_GetAudioStreamAvailable(channels[i].stream) == 0) JA_StopChannel(i);
}
}
}
return 30;
}
void JA_Init(const int freq, const SDL_AudioFormat format, const int num_channels) {
#ifdef DEBUG
SDL_SetLogPriority(SDL_LOG_CATEGORY_APPLICATION, SDL_LOG_PRIORITY_DEBUG);
#endif
SDL_Log("Iniciant JailAudio...");
JA_audioSpec = {format, num_channels, freq};
if (!sdlAudioDevice) SDL_CloseAudioDevice(sdlAudioDevice);
sdlAudioDevice = SDL_OpenAudioDevice(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, &JA_audioSpec);
SDL_Log((sdlAudioDevice == 0) ? "Failed to initialize SDL audio!\n" : "OK!\n");
for (int i = 0; i < JA_MAX_SIMULTANEOUS_CHANNELS; ++i) channels[i].state = JA_CHANNEL_FREE;
// SDL_PauseAudioDevice(sdlAudioDevice);
JA_timerID = SDL_AddTimer(30, JA_UpdateCallback, nullptr);
}
void JA_Quit() {
if (JA_timerID) SDL_RemoveTimer(JA_timerID);
if (!sdlAudioDevice) SDL_CloseAudioDevice(sdlAudioDevice);
sdlAudioDevice = 0;
}
JA_Music_t* JA_LoadMusic(Uint8* buffer, Uint32 length, const char* filename) {
JA_Music_t* music = new JA_Music_t();
int chan, samplerate;
short* output;
music->length = stb_vorbis_decode_memory(buffer, length, &chan, &samplerate, &output) * chan * 2;
music->spec.channels = chan;
music->spec.freq = samplerate;
music->spec.format = SDL_AUDIO_S16;
music->buffer = (Uint8*)SDL_malloc(music->length);
SDL_memcpy(music->buffer, output, music->length);
free(output);
music->pos = 0;
music->state = JA_MUSIC_STOPPED;
if (filename) {
music->filename = (char*)malloc(strlen(filename) + 1);
strcpy(music->filename, filename);
}
return music;
}
JA_Music_t* JA_LoadMusic(const char* filename) {
// [RZC 28/08/22] Carreguem primer el arxiu en memòria i després el descomprimim. Es algo més rapid.
FILE* f = fopen(filename, "rb");
fseek(f, 0, SEEK_END);
long fsize = ftell(f);
fseek(f, 0, SEEK_SET);
Uint8* buffer = (Uint8*)malloc(fsize + 1);
if (fread(buffer, fsize, 1, f) != 1) return NULL;
fclose(f);
JA_Music_t* music = JA_LoadMusic(buffer, fsize, filename);
free(buffer);
return music;
}
void JA_PlayMusic(JA_Music_t* music, const int loop) {
if (!JA_musicEnabled) return;
JA_StopMusic();
current_music = music;
current_music->pos = 0;
current_music->state = JA_MUSIC_PLAYING;
current_music->times = loop;
current_music->stream = SDL_CreateAudioStream(&current_music->spec, &JA_audioSpec);
if (!SDL_PutAudioStreamData(current_music->stream, current_music->buffer, current_music->length)) printf("[ERROR] SDL_PutAudioStreamData failed!\n");
SDL_SetAudioStreamGain(current_music->stream, JA_musicVolume);
if (!SDL_BindAudioStream(sdlAudioDevice, current_music->stream)) printf("[ERROR] SDL_BindAudioStream failed!\n");
// SDL_ResumeAudioStreamDevice(current_music->stream);
}
char* JA_GetMusicFilename(JA_Music_t* music) {
if (!music) music = current_music;
return music->filename;
}
void JA_PauseMusic() {
if (!JA_musicEnabled) return;
if (!current_music || current_music->state == JA_MUSIC_INVALID) return;
current_music->state = JA_MUSIC_PAUSED;
// SDL_PauseAudioStreamDevice(current_music->stream);
SDL_UnbindAudioStream(current_music->stream);
}
void JA_ResumeMusic() {
if (!JA_musicEnabled) return;
if (!current_music || current_music->state == JA_MUSIC_INVALID) return;
current_music->state = JA_MUSIC_PLAYING;
// SDL_ResumeAudioStreamDevice(current_music->stream);
SDL_BindAudioStream(sdlAudioDevice, current_music->stream);
}
void JA_StopMusic() {
if (!JA_musicEnabled) return;
if (!current_music || current_music->state == JA_MUSIC_INVALID) return;
current_music->pos = 0;
current_music->state = JA_MUSIC_STOPPED;
// SDL_PauseAudioStreamDevice(current_music->stream);
SDL_DestroyAudioStream(current_music->stream);
current_music->stream = nullptr;
free(current_music->filename);
current_music->filename = nullptr;
}
void JA_FadeOutMusic(const int milliseconds) {
if (!JA_musicEnabled) return;
if (current_music == NULL || current_music->state == JA_MUSIC_INVALID) return;
fading = true;
fade_start_time = SDL_GetTicks();
fade_duration = milliseconds;
fade_initial_volume = JA_musicVolume;
}
JA_Music_state JA_GetMusicState() {
if (!JA_musicEnabled) return JA_MUSIC_DISABLED;
if (!current_music) return JA_MUSIC_INVALID;
return current_music->state;
}
void JA_DeleteMusic(JA_Music_t* music) {
if (current_music == music) current_music = nullptr;
SDL_free(music->buffer);
if (music->stream) SDL_DestroyAudioStream(music->stream);
delete music;
}
float JA_SetMusicVolume(float volume) {
JA_musicVolume = SDL_clamp(volume, 0.0f, 1.0f);
if (current_music) SDL_SetAudioStreamGain(current_music->stream, JA_musicVolume);
return JA_musicVolume;
}
void JA_SetMusicPosition(float value) {
if (!current_music) return;
current_music->pos = value * current_music->spec.freq;
}
float JA_GetMusicPosition() {
if (!current_music) return 0;
return float(current_music->pos) / float(current_music->spec.freq);
}
void JA_EnableMusic(const bool value) {
if (!value && current_music && (current_music->state == JA_MUSIC_PLAYING)) JA_StopMusic();
JA_musicEnabled = value;
}
JA_Sound_t* JA_NewSound(Uint8* buffer, Uint32 length) {
JA_Sound_t* sound = new JA_Sound_t();
sound->buffer = buffer;
sound->length = length;
return sound;
}
JA_Sound_t* JA_LoadSound(uint8_t* buffer, uint32_t size) {
JA_Sound_t* sound = new JA_Sound_t();
SDL_LoadWAV_IO(SDL_IOFromMem(buffer, size), 1, &sound->spec, &sound->buffer, &sound->length);
return sound;
}
JA_Sound_t* JA_LoadSound(const char* filename) {
JA_Sound_t* sound = new JA_Sound_t();
SDL_LoadWAV(filename, &sound->spec, &sound->buffer, &sound->length);
return sound;
}
int JA_PlaySound(JA_Sound_t* sound, const int loop) {
if (!JA_soundEnabled) return -1;
int channel = 0;
while (channel < JA_MAX_SIMULTANEOUS_CHANNELS && channels[channel].state != JA_CHANNEL_FREE) { channel++; }
if (channel == JA_MAX_SIMULTANEOUS_CHANNELS) channel = 0;
JA_StopChannel(channel);
channels[channel].sound = sound;
channels[channel].times = loop;
channels[channel].pos = 0;
channels[channel].state = JA_CHANNEL_PLAYING;
channels[channel].stream = SDL_CreateAudioStream(&channels[channel].sound->spec, &JA_audioSpec);
SDL_PutAudioStreamData(channels[channel].stream, channels[channel].sound->buffer, channels[channel].sound->length);
SDL_SetAudioStreamGain(channels[channel].stream, JA_soundVolume);
SDL_BindAudioStream(sdlAudioDevice, channels[channel].stream);
return channel;
}
int JA_PlaySoundOnChannel(JA_Sound_t* sound, const int channel, const int loop) {
if (!JA_soundEnabled) return -1;
if (channel < 0 || channel >= JA_MAX_SIMULTANEOUS_CHANNELS) return -1;
JA_StopChannel(channel);
channels[channel].sound = sound;
channels[channel].times = loop;
channels[channel].pos = 0;
channels[channel].state = JA_CHANNEL_PLAYING;
channels[channel].stream = SDL_CreateAudioStream(&channels[channel].sound->spec, &JA_audioSpec);
SDL_PutAudioStreamData(channels[channel].stream, channels[channel].sound->buffer, channels[channel].sound->length);
SDL_SetAudioStreamGain(channels[channel].stream, JA_soundVolume);
SDL_BindAudioStream(sdlAudioDevice, channels[channel].stream);
return channel;
}
void JA_DeleteSound(JA_Sound_t* sound) {
for (int i = 0; i < JA_MAX_SIMULTANEOUS_CHANNELS; i++) {
if (channels[i].sound == sound) JA_StopChannel(i);
}
SDL_free(sound->buffer);
delete sound;
}
void JA_PauseChannel(const int channel) {
if (!JA_soundEnabled) return;
if (channel == -1) {
for (int i = 0; i < JA_MAX_SIMULTANEOUS_CHANNELS; i++)
if (channels[i].state == JA_CHANNEL_PLAYING) {
channels[i].state = JA_CHANNEL_PAUSED;
// SDL_PauseAudioStreamDevice(channels[i].stream);
SDL_UnbindAudioStream(channels[i].stream);
}
} else if (channel >= 0 && channel < JA_MAX_SIMULTANEOUS_CHANNELS) {
if (channels[channel].state == JA_CHANNEL_PLAYING) {
channels[channel].state = JA_CHANNEL_PAUSED;
// SDL_PauseAudioStreamDevice(channels[channel].stream);
SDL_UnbindAudioStream(channels[channel].stream);
}
}
}
void JA_ResumeChannel(const int channel) {
if (!JA_soundEnabled) return;
if (channel == -1) {
for (int i = 0; i < JA_MAX_SIMULTANEOUS_CHANNELS; i++)
if (channels[i].state == JA_CHANNEL_PAUSED) {
channels[i].state = JA_CHANNEL_PLAYING;
// SDL_ResumeAudioStreamDevice(channels[i].stream);
SDL_BindAudioStream(sdlAudioDevice, channels[i].stream);
}
} else if (channel >= 0 && channel < JA_MAX_SIMULTANEOUS_CHANNELS) {
if (channels[channel].state == JA_CHANNEL_PAUSED) {
channels[channel].state = JA_CHANNEL_PLAYING;
// SDL_ResumeAudioStreamDevice(channels[channel].stream);
SDL_BindAudioStream(sdlAudioDevice, channels[channel].stream);
}
}
}
void JA_StopChannel(const int channel) {
if (!JA_soundEnabled) return;
if (channel == -1) {
for (int i = 0; i < JA_MAX_SIMULTANEOUS_CHANNELS; i++) {
if (channels[i].state != JA_CHANNEL_FREE) SDL_DestroyAudioStream(channels[i].stream);
channels[i].stream = nullptr;
channels[i].state = JA_CHANNEL_FREE;
channels[i].pos = 0;
channels[i].sound = NULL;
}
} else if (channel >= 0 && channel < JA_MAX_SIMULTANEOUS_CHANNELS) {
if (channels[channel].state != JA_CHANNEL_FREE) SDL_DestroyAudioStream(channels[channel].stream);
channels[channel].stream = nullptr;
channels[channel].state = JA_CHANNEL_FREE;
channels[channel].pos = 0;
channels[channel].sound = NULL;
}
}
JA_Channel_state JA_GetChannelState(const int channel) {
if (!JA_soundEnabled) return JA_SOUND_DISABLED;
if (channel < 0 || channel >= JA_MAX_SIMULTANEOUS_CHANNELS) return JA_CHANNEL_INVALID;
return channels[channel].state;
}
float JA_SetSoundVolume(float volume) {
JA_soundVolume = SDL_clamp(volume, 0.0f, 1.0f);
for (int i = 0; i < JA_MAX_SIMULTANEOUS_CHANNELS; i++)
if ((channels[i].state == JA_CHANNEL_PLAYING) || (channels[i].state == JA_CHANNEL_PAUSED))
SDL_SetAudioStreamGain(channels[i].stream, JA_soundVolume);
return JA_soundVolume;
}
void JA_EnableSound(const bool value) {
for (int i = 0; i < JA_MAX_SIMULTANEOUS_CHANNELS; i++) {
if (channels[i].state == JA_CHANNEL_PLAYING) JA_StopChannel(i);
}
JA_soundEnabled = value;
}
float JA_SetVolume(float volume) {
JA_SetSoundVolume(JA_SetMusicVolume(volume) / 2.0f);
return JA_musicVolume;
}
#endif

View File

@@ -1,49 +0,0 @@
#pragma once
#include <SDL3/SDL.h>
enum JA_Channel_state { JA_CHANNEL_INVALID,
JA_CHANNEL_FREE,
JA_CHANNEL_PLAYING,
JA_CHANNEL_PAUSED,
JA_SOUND_DISABLED };
enum JA_Music_state { JA_MUSIC_INVALID,
JA_MUSIC_PLAYING,
JA_MUSIC_PAUSED,
JA_MUSIC_STOPPED,
JA_MUSIC_DISABLED };
struct JA_Sound_t;
struct JA_Music_t;
void JA_Init(const int freq, const SDL_AudioFormat format, const int num_channels);
void JA_Quit();
JA_Music_t* JA_LoadMusic(const char* filename);
JA_Music_t* JA_LoadMusic(Uint8* buffer, Uint32 length, const char* filename = nullptr);
void JA_PlayMusic(JA_Music_t* music, const int loop = -1);
char* JA_GetMusicFilename(JA_Music_t* music = nullptr);
void JA_PauseMusic();
void JA_ResumeMusic();
void JA_StopMusic();
void JA_FadeOutMusic(const int milliseconds);
JA_Music_state JA_GetMusicState();
void JA_DeleteMusic(JA_Music_t* music);
float JA_SetMusicVolume(float volume);
void JA_SetMusicPosition(float value);
float JA_GetMusicPosition();
void JA_EnableMusic(const bool value);
JA_Sound_t* JA_NewSound(Uint8* buffer, Uint32 length);
JA_Sound_t* JA_LoadSound(Uint8* buffer, Uint32 length);
JA_Sound_t* JA_LoadSound(const char* filename);
int JA_PlaySound(JA_Sound_t* sound, const int loop = 0);
int JA_PlaySoundOnChannel(JA_Sound_t* sound, const int channel, const int loop = 0);
void JA_PauseChannel(const int channel);
void JA_ResumeChannel(const int channel);
void JA_StopChannel(const int channel);
JA_Channel_state JA_GetChannelState(const int channel);
void JA_DeleteSound(JA_Sound_t* sound);
float JA_SetSoundVolume(float volume);
void JA_EnableSound(const bool value);
float JA_SetVolume(float volume);

View File

@@ -1,9 +1,10 @@
#include "core/jail/jdraw8.hpp" #include "core/jail/jdraw8.hpp"
#include <fstream> #include <fstream>
#include <string>
#include "core/jail/jfile.hpp" #include "core/resources/resource_cache.hpp"
#include "core/system/director.hpp" #include "core/resources/resource_helper.hpp"
#if defined(__clang__) #if defined(__clang__)
#pragma clang diagnostic push #pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wunused-but-set-variable" #pragma clang diagnostic ignored "-Wunused-but-set-variable"
@@ -18,20 +19,23 @@
#pragma GCC diagnostic pop #pragma GCC diagnostic pop
#endif #endif
JD8_Surface screen = NULL; JD8_Surface screen = nullptr;
JD8_Palette main_palette = NULL; JD8_Palette main_palette = nullptr;
Uint32* pixel_data = NULL; Uint32* pixel_data = nullptr;
void JD8_Init() { void JD8_Init() {
screen = (JD8_Surface)calloc(1, 64000); screen = new Uint8[64000]{};
main_palette = (JD8_Palette)calloc(1, 768); main_palette = new Color[256]{};
pixel_data = (Uint32*)calloc(1, 320 * 200 * 4); pixel_data = new Uint32[320 * 200]{};
} }
void JD8_Quit() { void JD8_Quit() {
if (screen != NULL) free(screen); delete[] screen;
if (main_palette != NULL) free(main_palette); delete[] main_palette;
if (pixel_data != NULL) free(pixel_data); delete[] pixel_data;
screen = nullptr;
main_palette = nullptr;
pixel_data = nullptr;
} }
void JD8_ClearScreen(Uint8 color) { void JD8_ClearScreen(Uint8 color) {
@@ -39,45 +43,71 @@ void JD8_ClearScreen(Uint8 color) {
} }
JD8_Surface JD8_NewSurface() { JD8_Surface JD8_NewSurface() {
JD8_Surface surface = (JD8_Surface)malloc(64000); return new Uint8[64000]{};
memset(surface, 0, 64000); }
return surface;
// Helper intern: deriva el basename d'una ruta per a buscar al Cache.
static std::string jd8_basename(const char* file) {
std::string s = file;
auto pos = s.find_last_of("/\\");
return pos == std::string::npos ? s : s.substr(pos + 1);
} }
JD8_Surface JD8_LoadSurface(const char* file) { JD8_Surface JD8_LoadSurface(const char* file) {
int filesize = 0; // Prova primer el Resource::Cache. Si l'asset és precarregat, copiem
char* buffer = file_getfilebuffer(file, filesize); // els 64KB des del cache (microsegons) i ens estalviem la decodificació
// GIF. Mantenim el contracte de la funció: el caller rep un buffer
// fresc que ha d'alliberar amb JD8_FreeSurface.
if (Resource::Cache::get() != nullptr) {
try {
const auto& cached = Resource::Cache::get()->getSurfacePixels(jd8_basename(file));
JD8_Surface image = JD8_NewSurface();
memcpy(image, cached.data(), 64000);
return image;
} catch (const std::exception&) {
// No està al cache (asset no llistat al manifest). Fallback.
}
}
auto buffer = ResourceHelper::loadFile(file);
unsigned short w, h; unsigned short w, h;
Uint8* pixels = LoadGif((unsigned char*)buffer, &w, &h); Uint8* pixels = LoadGif(buffer.data(), &w, &h);
if (pixels == nullptr) {
free(buffer);
if (pixels == NULL) {
printf("Unable to load bitmap: %s\n", SDL_GetError()); printf("Unable to load bitmap: %s\n", SDL_GetError());
exit(1); exit(1);
} }
JD8_Surface image = JD8_NewSurface(); JD8_Surface image = JD8_NewSurface();
memcpy(image, pixels, 64000); memcpy(image, pixels, 64000);
free(pixels); free(pixels);
return image; return image;
} }
JD8_Palette JD8_LoadPalette(const char* file) { JD8_Palette JD8_LoadPalette(const char* file) {
int filesize = 0; // Sempre retorna un buffer de 256 colors reservat amb `new Color[256]`
char* buffer = NULL; // — el caller és responsable d'alliberar-lo amb `delete[]` (o lliurar-ne
buffer = file_getfilebuffer(file, filesize); // l'ownership a `JD8_SetScreenPalette`).
JD8_Palette palette = new Color[256];
JD8_Palette palette = (JD8_Palette)LoadPalette((unsigned char*)buffer); if (Resource::Cache::get() != nullptr) {
try {
const auto& cached = Resource::Cache::get()->getPaletteBytes(jd8_basename(file));
memcpy(palette, cached.data(), 768);
return palette;
} catch (const std::exception&) {
// No està al cache — fallback a lectura + LoadPalette.
}
}
auto buffer = ResourceHelper::loadFile(file);
Uint8* raw = LoadPalette(buffer.data()); // external malloc
memcpy(palette, raw, 768);
free(raw);
return palette; return palette;
} }
void JD8_SetScreenPalette(JD8_Palette palette) { void JD8_SetScreenPalette(JD8_Palette palette) {
if (main_palette == palette) return; if (main_palette == palette) return;
if (main_palette != NULL) free(main_palette); delete[] main_palette;
main_palette = palette; main_palette = palette;
} }
@@ -87,6 +117,23 @@ void JD8_FillSquare(int ini, int height, Uint8 color) {
memset(&screen[offset], color, size); memset(&screen[offset], color, size);
} }
void JD8_FillRect(int x, int y, int w, int h, Uint8 color) {
if (x < 0) {
w += x;
x = 0;
}
if (y < 0) {
h += y;
y = 0;
}
if (x + w > 320) w = 320 - x;
if (y + h > 200) h = 200 - y;
if (w <= 0 || h <= 0) return;
for (int row = y; row < y + h; ++row) {
memset(&screen[x + (row * 320)], color, w);
}
}
void JD8_Blit(JD8_Surface surface) { void JD8_Blit(JD8_Surface surface) {
memcpy(screen, surface, 64000); memcpy(screen, surface, 64000);
} }
@@ -159,17 +206,24 @@ void JD8_BlitCKToSurface(int x, int y, JD8_Surface surface, int sx, int sy, int
} }
void JD8_Flip() { void JD8_Flip() {
// Converteix el framebuffer indexat (paletted) a ARGB (pixel_data).
// El Director crida aquesta funció després del tick de cada escena
// per preparar el frame abans de presentar-lo. Ja no fa yield —
// tot corre en un sol thread sense fibers des de Phase B.2.
for (int x = 0; x < 320; x++) { for (int x = 0; x < 320; x++) {
for (int y = 0; y < 200; y++) { for (int y = 0; y < 200; y++) {
Uint32 color = 0xFF000000 + main_palette[screen[x + (y * 320)]].r + (main_palette[screen[x + (y * 320)]].g << 8) + (main_palette[screen[x + (y * 320)]].b << 16); Uint32 color = 0xFF000000 + main_palette[screen[x + (y * 320)]].r + (main_palette[screen[x + (y * 320)]].g << 8) + (main_palette[screen[x + (y * 320)]].b << 16);
pixel_data[x + (y * 320)] = color; pixel_data[x + (y * 320)] = color;
} }
} }
Director::get()->publishFrame(pixel_data); }
Uint32* JD8_GetFramebuffer() {
return pixel_data;
} }
void JD8_FreeSurface(JD8_Surface surface) { void JD8_FreeSurface(JD8_Surface surface) {
free(surface); delete[] surface;
} }
Uint8 JD8_GetPixel(JD8_Surface surface, int x, int y) { Uint8 JD8_GetPixel(JD8_Surface surface, int x, int y) {
@@ -186,44 +240,78 @@ void JD8_SetPaletteColor(Uint8 index, Uint8 r, Uint8 g, Uint8 b) {
main_palette[index].b = b << 2; main_palette[index].b = b << 2;
} }
void JD8_FadeOut() { // Màquina d'estats del fade. Evita que JD8_FadeOut/JD8_FadeToPal hagen de
for (int j = 0; j < 32; j++) { // mantindre whiles interns. Cada pas aplica un delta a la paleta activa i
// el caller decideix quan fer Flip.
namespace {
enum class FadeType {
None = 0,
Out,
ToPal,
};
constexpr int FADE_STEPS = 32;
FadeType fade_type = FadeType::None;
Color fade_target[256];
int fade_step = 0;
void apply_fade_step() {
if (fade_type == FadeType::Out) {
for (int i = 0; i < 256; i++) { for (int i = 0; i < 256; i++) {
if (main_palette[i].r >= 8) main_palette[i].r = main_palette[i].r >= 8 ? main_palette[i].r - 8 : 0;
main_palette[i].r -= 8; main_palette[i].g = main_palette[i].g >= 8 ? main_palette[i].g - 8 : 0;
else main_palette[i].b = main_palette[i].b >= 8 ? main_palette[i].b - 8 : 0;
main_palette[i].r = 0; }
if (main_palette[i].g >= 8) } else if (fade_type == FadeType::ToPal) {
main_palette[i].g -= 8; for (int i = 0; i < 256; i++) {
else main_palette[i].r = main_palette[i].r <= int(fade_target[i].r) - 8
main_palette[i].g = 0; ? main_palette[i].r + 8
if (main_palette[i].b >= 8) : fade_target[i].r;
main_palette[i].b -= 8; main_palette[i].g = main_palette[i].g <= int(fade_target[i].g) - 8
else ? main_palette[i].g + 8
main_palette[i].b = 0; : fade_target[i].g;
main_palette[i].b = main_palette[i].b <= int(fade_target[i].b) - 8
? main_palette[i].b + 8
: fade_target[i].b;
} }
JD8_Flip();
} }
} }
#define MAX(a, b) (a) > (b) ? (a) : (b) } // namespace
void JD8_FadeToPal(JD8_Palette pal) { void JD8_FadeStartOut() {
for (int j = 0; j < 32; j++) { fade_type = FadeType::Out;
for (int i = 0; i < 256; i++) { fade_step = 0;
if (main_palette[i].r <= int(pal[i].r) - 8)
main_palette[i].r += 8;
else
main_palette[i].r = pal[i].r;
if (main_palette[i].g <= int(pal[i].g) - 8)
main_palette[i].g += 8;
else
main_palette[i].g = pal[i].g;
if (main_palette[i].b <= int(pal[i].b) - 8)
main_palette[i].b += 8;
else
main_palette[i].b = pal[i].b;
} }
JD8_Flip();
void JD8_FadeStartToPal(JD8_Palette pal) {
fade_type = FadeType::ToPal;
memcpy(fade_target, pal, sizeof(Color) * 256);
fade_step = 0;
} }
bool JD8_FadeIsActive() {
return fade_type != FadeType::None;
} }
bool JD8_FadeTickStep() {
if (fade_type == FadeType::None) return true;
apply_fade_step();
fade_step++;
if (fade_step >= FADE_STEPS) {
fade_type = FadeType::None;
return true;
}
return false;
}
// Els shims bloquejants `JD8_FadeOut` i `JD8_FadeToPal` han estat
// eliminats a Phase B.2: feien un bucle de 32 iteracions amb `JD8_Flip`
// entre cada una que només funcionava mentre l'entorn tenia fibers i
// `JD8_Flip` cedia el control al Director. Ara tot fade es fa tick a
// tick via `scenes::PaletteFade` (que encapsula `JD8_FadeStartOut` /
// `JD8_FadeStartToPal` + `JD8_FadeTickStep`).

View File

@@ -7,8 +7,8 @@ struct Color {
Uint8 b; Uint8 b;
}; };
typedef Uint8* JD8_Surface; using JD8_Surface = Uint8*;
typedef Color* JD8_Palette; using JD8_Palette = Color*;
void JD8_Init(); void JD8_Init();
@@ -26,6 +26,10 @@ void JD8_SetScreenPalette(JD8_Palette palette);
void JD8_FillSquare(int ini, int height, Uint8 color); void JD8_FillSquare(int ini, int height, Uint8 color);
// Omple un rectangle arbitrari de la pantalla amb un color paletat.
// Pensat per a UI senzilla (barra de progrés del BootLoader, etc.).
void JD8_FillRect(int x, int y, int w, int h, Uint8 color);
void JD8_Blit(JD8_Surface surface); void JD8_Blit(JD8_Surface surface);
void JD8_Blit(int x, int y, JD8_Surface surface, int sx, int sy, int sw, int sh); void JD8_Blit(int x, int y, JD8_Surface surface, int sx, int sy, int sw, int sh);
@@ -40,8 +44,15 @@ void JD8_BlitCKScroll(int y, JD8_Surface surface, int sx, int sy, int sh, Uint8
void JD8_BlitCKToSurface(int x, int y, JD8_Surface surface, int sx, int sy, int sw, int sh, JD8_Surface dest, Uint8 colorkey); void JD8_BlitCKToSurface(int x, int y, JD8_Surface surface, int sx, int sy, int sw, int sh, JD8_Surface dest, Uint8 colorkey);
// Converteix la pantalla indexada a ARGB. El Director crida aquesta
// funció al final de cada tick i després llegeix el framebuffer via
// JD8_GetFramebuffer() per presentar-lo.
void JD8_Flip(); void JD8_Flip();
// Accés al framebuffer ARGB de 320x200 actualitzat per l'última crida a
// JD8_Flip(). Propietat de jdraw8 — el caller no ha de lliberar-lo.
Uint32* JD8_GetFramebuffer();
void JD8_FreeSurface(JD8_Surface surface); void JD8_FreeSurface(JD8_Surface surface);
Uint8 JD8_GetPixel(JD8_Surface surface, int x, int y); Uint8 JD8_GetPixel(JD8_Surface surface, int x, int y);
@@ -50,9 +61,17 @@ void JD8_PutPixel(JD8_Surface surface, int x, int y, Uint8 pixel);
void JD8_SetPaletteColor(Uint8 index, Uint8 r, Uint8 g, Uint8 b); void JD8_SetPaletteColor(Uint8 index, Uint8 r, Uint8 g, Uint8 b);
void JD8_FadeOut(); // API de fade no bloquejant (màquina d'estats). `FadeStart*` inicia el
// fade; `FadeTickStep` aplica un pas i retorna `true` quan el fade ha
void JD8_FadeToPal(JD8_Palette pal); // acabat. Un pas correspon visualment a una iteració del fade original
// (32 passos en total). El caller és responsable de fer el Flip entre
// passos si el vol veure animat. `FadeIsActive` permet saber si hi ha
// un fade en curs per a enllaçar-lo amb un altre subsistema.
// L'embolcall `scenes::PaletteFade` ho fa més idiomàtic per a escenes.
void JD8_FadeStartOut();
void JD8_FadeStartToPal(JD8_Palette pal);
bool JD8_FadeTickStep();
bool JD8_FadeIsActive();
// JD_Font JD_LoadFont( char *file, int width, int height); // JD_Font JD_LoadFont( char *file, int width, int height);

View File

@@ -1,15 +1,10 @@
#include "core/jail/jfile.hpp" #include "core/jail/jfile.hpp"
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <unistd.h> #include <unistd.h>
#include <filesystem> #include <filesystem>
#include <fstream> #include <fstream>
#include <iostream>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -17,202 +12,113 @@
#include <pwd.h> #include <pwd.h>
#endif #endif
#define DEFAULT_FILENAME "data.jf2" namespace {
#define DEFAULT_FOLDER "data/"
#define CONFIG_FILENAME "config.txt"
struct file_t { struct keyvalue {
std::string path; std::string key;
uint32_t size; std::string value;
uint32_t offset;
}; };
std::vector<file_t> toc; std::vector<keyvalue> config;
std::string resource_folder;
std::string config_folder;
/* El std::map me fa coses rares, vaig a usar un good old std::vector amb una estructura key,value propia i au, que sempre funciona */ void load_config_values() {
struct keyvalue_t { config.clear();
std::string key, value; const std::string config_file = config_folder + "/config.txt";
}; std::ifstream fi(config_file);
if (!fi.is_open()) return;
char* resource_filename = NULL; std::string line;
char* resource_folder = NULL; while (std::getline(fi, line)) {
int file_source = SOURCE_FILE; const auto eq = line.find('=');
char scratch[255]; if (eq == std::string::npos) continue;
static std::string config_folder; config.push_back({line.substr(0, eq), line.substr(eq + 1)});
std::vector<keyvalue_t> config;
void file_setresourcefilename(const char* str) {
if (resource_filename != NULL) free(resource_filename);
resource_filename = (char*)malloc(strlen(str) + 1);
strcpy(resource_filename, str);
} }
}
void save_config_values() {
const std::string config_file = config_folder + "/config.txt";
std::ofstream fo(config_file);
if (!fo.is_open()) return;
for (const auto& pair : config) {
fo << pair.key << '=' << pair.value << '\n';
}
}
} // namespace
void file_setresourcefolder(const char* str) { void file_setresourcefolder(const char* str) {
if (resource_folder != NULL) free(resource_folder); resource_folder = str;
resource_folder = (char*)malloc(strlen(str) + 1);
strcpy(resource_folder, str);
} }
void file_setsource(const int src) { const char* file_getresourcefolder() {
file_source = src % 2; // mod 2 so it always is a valid value, 0 (file) or 1 (folder) return resource_folder.c_str();
if (src == SOURCE_FOLDER && resource_folder == NULL) file_setresourcefolder(DEFAULT_FOLDER);
} }
bool file_getdictionary() { // Crea la carpeta del sistema on guardar les dades.
if (resource_filename == NULL) file_setresourcefilename(DEFAULT_FILENAME); // Accepta rutes amb subdirectoris (ex: "jailgames/aee") i crea tota la jerarquia.
std::ifstream fi(resource_filename, std::ios::binary);
if (!fi.is_open()) return false;
char header[4];
fi.read(header, 4);
uint32_t num_files, toc_offset;
fi.read((char*)&num_files, 4);
fi.read((char*)&toc_offset, 4);
fi.seekg(toc_offset);
for (uint32_t i = 0; i < num_files; ++i) {
uint32_t file_offset, file_size;
fi.read((char*)&file_offset, 4);
fi.read((char*)&file_size, 4);
uint8_t path_size;
fi.read((char*)&path_size, 1);
char file_name[256];
fi.read(file_name, path_size);
file_name[path_size] = 0;
std::string filename = file_name;
toc.push_back({filename, file_size, file_offset});
}
fi.close();
return true;
}
char* file_getfilenamewithfolder(const char* filename) {
strcpy(scratch, resource_folder);
strcat(scratch, filename);
return scratch;
}
FILE* file_getfilepointer(const char* resourcename, int& filesize, const bool binary) {
if (file_source == SOURCE_FILE and toc.size() == 0) {
if (not file_getdictionary()) file_setsource(SOURCE_FOLDER);
}
FILE* f;
if (file_source == SOURCE_FILE) {
bool found = false;
uint32_t count = 0;
while (!found && count < toc.size()) {
found = (std::string(resourcename) == toc[count].path);
if (!found) count++;
}
if (!found) {
perror("El recurs no s'ha trobat en l'arxiu de recursos");
exit(1);
}
filesize = toc[count].size;
f = fopen(resource_filename, binary ? "rb" : "r");
if (not f) {
perror("No s'ha pogut obrir l'arxiu de recursos");
exit(1);
}
fseek(f, toc[count].offset, SEEK_SET);
} else {
f = fopen(file_getfilenamewithfolder(resourcename), binary ? "rb" : "r");
fseek(f, 0, SEEK_END);
filesize = ftell(f);
fseek(f, 0, SEEK_SET);
}
return f;
}
char* file_getfilebuffer(const char* resourcename, int& filesize, const bool zero_terminate) {
FILE* f = file_getfilepointer(resourcename, filesize, true);
char* buffer = (char*)malloc(zero_terminate ? filesize : filesize + 1);
fread(buffer, filesize, 1, f);
if (zero_terminate) buffer[filesize] = 0;
fclose(f);
return buffer;
}
// Crea la carpeta del sistema donde guardar datos.
// Acepta rutas con subdirectorios (ej: "jailgames/aee") y crea toda la jerarquía.
void file_setconfigfolder(const char* foldername) { void file_setconfigfolder(const char* foldername) {
#ifdef _WIN32 #ifdef _WIN32
config_folder = std::string(getenv("APPDATA")) + "/" + foldername; const char* base = getenv("APPDATA");
if (!base) base = "C:/";
config_folder = std::string(base) + "/" + foldername;
#elif __APPLE__ #elif __APPLE__
struct passwd* pw = getpwuid(getuid()); struct passwd* pw = getpwuid(getuid());
const char* homedir = pw->pw_dir; const char* homedir = (pw && pw->pw_dir) ? pw->pw_dir : nullptr;
if (!homedir) homedir = getenv("HOME");
if (!homedir) homedir = "/tmp";
config_folder = std::string(homedir) + "/Library/Application Support/" + foldername; config_folder = std::string(homedir) + "/Library/Application Support/" + foldername;
#elif __linux__ #elif __linux__
// Nota emscripten: `__linux__` també està definit, però `getpwuid` pot
// retornar nullptr (sense /etc/passwd al MEMFS) o un passwd amb pw_dir
// buit. Amb els fallbacks HOME → /tmp evitem crashejar al primer
// arranque dins del navegador. La config no persistirà entre recàrregues
// (MEMFS és volàtil); caldria IDBFS si volguéssem persistència a web.
struct passwd* pw = getpwuid(getuid()); struct passwd* pw = getpwuid(getuid());
const char* homedir = pw->pw_dir; const char* homedir = (pw && pw->pw_dir && pw->pw_dir[0]) ? pw->pw_dir : nullptr;
if (!homedir || !homedir[0]) homedir = getenv("HOME");
if (!homedir || !homedir[0]) homedir = "/tmp";
config_folder = std::string(homedir) + "/.config/" + foldername; config_folder = std::string(homedir) + "/.config/" + foldername;
#endif #endif
std::filesystem::create_directories(config_folder); if (config_folder.empty()) {
config_folder = "/tmp/jailgames_config";
}
std::error_code ec;
std::filesystem::create_directories(config_folder, ec);
// A emscripten/MEMFS create_directories pot fallar (p.ex. parent
// read-only o libc++ amb path empty-check estricte). La config és
// volàtil al navegador de totes formes: ignorem l'error i continuem.
} }
const char* file_getconfigfolder() { const char* file_getconfigfolder() {
static std::string folder; thread_local std::string folder;
folder = config_folder + "/"; folder = config_folder + "/";
return folder.c_str(); return folder.c_str();
} }
void file_loadconfigvalues() {
config.clear();
std::string config_file = config_folder + "/config.txt";
FILE* f = fopen(config_file.c_str(), "r");
if (!f) return;
char line[1024];
while (fgets(line, sizeof(line), f)) {
char* value = strchr(line, '=');
if (value) {
*value = '\0';
value++;
value[strlen(value) - 1] = '\0';
config.push_back({line, value});
}
}
fclose(f);
}
void file_saveconfigvalues() {
std::string config_file = config_folder + "/config.txt";
FILE* f = fopen(config_file.c_str(), "w");
if (f) {
for (auto pair : config) {
fprintf(f, "%s=%s\n", pair.key.c_str(), pair.value.c_str());
}
fclose(f);
}
}
const char* file_getconfigvalue(const char* key) { const char* file_getconfigvalue(const char* key) {
if (config.empty()) file_loadconfigvalues(); if (config.empty()) load_config_values();
for (auto pair : config) { for (const auto& pair : config) {
if (pair.key == std::string(key)) { if (pair.key == key) {
strcpy(scratch, pair.value.c_str()); thread_local std::string value_cache;
return scratch; value_cache = pair.value;
return value_cache.c_str();
} }
} }
return NULL; return nullptr;
} }
void file_setconfigvalue(const char* key, const char* value) { void file_setconfigvalue(const char* key, const char* value) {
if (config.empty()) file_loadconfigvalues(); if (config.empty()) load_config_values();
for (auto& pair : config) { for (auto& pair : config) {
if (pair.key == std::string(key)) { if (pair.key == key) {
pair.value = value; pair.value = value;
file_saveconfigvalues(); save_config_values();
return; return;
} }
} }
config.push_back({key, value}); config.push_back({std::string(key), std::string(value)});
file_saveconfigvalues(); save_config_values();
return;
} }

View File

@@ -1,18 +1,10 @@
#pragma once #pragma once
#include <stdio.h>
#define SOURCE_FILE 0
#define SOURCE_FOLDER 1
void file_setconfigfolder(const char* foldername); void file_setconfigfolder(const char* foldername);
const char* file_getconfigfolder(); const char* file_getconfigfolder();
void file_setresourcefilename(const char* str);
void file_setresourcefolder(const char* str); void file_setresourcefolder(const char* str);
void file_setsource(const int src); const char* file_getresourcefolder();
FILE* file_getfilepointer(const char* resourcename, int& filesize, const bool binary = false);
char* file_getfilebuffer(const char* resourcename, int& filesize, const bool zero_terminate = false);
const char* file_getconfigvalue(const char* key); const char* file_getconfigvalue(const char* key);
void file_setconfigvalue(const char* key, const char* value); void file_setconfigvalue(const char* key, const char* value);

View File

@@ -1,14 +1,19 @@
#include "core/jail/jgame.hpp" #include "core/jail/jgame.hpp"
bool eixir = false; namespace {
Uint32 updateTicks = 0;
Uint32 updateTime = 0; bool quitting = false;
Uint32 update_ticks = 0;
Uint32 update_time = 0;
Uint32 cycle_counter = 0; Uint32 cycle_counter = 0;
Uint32 last_delta_time = 0;
} // namespace
void JG_Init() { void JG_Init() {
SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO); SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO);
// SDL_WM_SetCaption( title, NULL ); update_time = SDL_GetTicks();
updateTime = SDL_GetTicks(); last_delta_time = update_time;
} }
void JG_Finalize() { void JG_Finalize() {
@@ -16,27 +21,37 @@ void JG_Finalize() {
} }
void JG_QuitSignal() { void JG_QuitSignal() {
eixir = true; quitting = true;
} }
bool JG_Quitting() { bool JG_Quitting() {
return eixir; return quitting;
} }
void JG_SetUpdateTicks(Uint32 milliseconds) { void JG_SetUpdateTicks(Uint32 milliseconds) {
updateTicks = milliseconds; update_ticks = milliseconds;
} }
bool JG_ShouldUpdate() { bool JG_ShouldUpdate() {
if (SDL_GetTicks() - updateTime > updateTicks) { const Uint32 now = SDL_GetTicks();
updateTime = SDL_GetTicks(); if (now - update_time > update_ticks) {
update_time = now;
cycle_counter++; cycle_counter++;
return true; return true;
} else {
return false;
} }
// No toca update — retornem false sense més. Des de Phase B.2 ja no
// hi ha fibers: cap caller fa spin-waits (`while (!JG_ShouldUpdate())`)
// i el Director pren el control del main loop frame a frame.
return false;
} }
Uint32 JG_GetCycleCounter() { Uint32 JG_GetCycleCounter() {
return cycle_counter; return cycle_counter;
} }
Uint32 JG_GetDeltaMs() {
const Uint32 now = SDL_GetTicks();
const Uint32 delta = now - last_delta_time;
last_delta_time = now;
return delta;
}

View File

@@ -14,3 +14,7 @@ void JG_SetUpdateTicks(Uint32 milliseconds);
bool JG_ShouldUpdate(); bool JG_ShouldUpdate();
Uint32 JG_GetCycleCounter(); Uint32 JG_GetCycleCounter();
// Temps transcorregut (en ms) des de l'última crida a JG_GetDeltaMs.
// Helper per a la migració progressiva a time-based (Fase 4+).
Uint32 JG_GetDeltaMs();

View File

@@ -1,39 +1,63 @@
#include "core/jail/jinput.hpp" #include "core/jail/jinput.hpp"
#include <string> #include <cstring>
#include "core/system/director.hpp" #include "core/system/director.hpp"
namespace {
// keystates és actualitzat per SDL internament. Des del joc només fem lectures. // keystates és actualitzat per SDL internament. Des del joc només fem lectures.
const bool* keystates = nullptr; const bool* keystates = nullptr;
Uint8 cheat[5];
bool key_pressed = false;
int waitTime = 0;
void JI_DisableKeyboard(Uint32 time) { // Buffer dels últims 5 caràcters tecle. Emmagatzemem caràcters ASCII
waitTime = time; // lowercase (traduïts des de SDL_Scancode) per a poder comparar directament
// amb les cadenes dels cheats ("reviu", "alone", "obert").
Uint8 cheat[5] = {0, 0, 0, 0, 0};
bool key_pressed = false;
// Temps restant en mil·lisegons durant el qual JI_KeyPressed/JI_AnyKey
// retornen false. Utilitzat per a evitar que pulsacions fortuïtes
// saltin cinemàtiques al començament.
float wait_ms = 0.0f;
// Per a calcular el delta entre crides a JI_Update sense que els callers
// hagen de passar-lo explícitament. Es reinicia a la primera crida.
Uint64 last_update_tick = 0;
bool input_blocked = false;
Uint8 virtual_keystates[JI_VSRC_COUNT][SDL_SCANCODE_COUNT] = {{0}};
Uint8 scancode_to_ascii(Uint8 scancode) {
if (scancode >= SDL_SCANCODE_A && scancode <= SDL_SCANCODE_Z) {
return static_cast<Uint8>('a' + (scancode - SDL_SCANCODE_A));
}
return 0;
} }
static bool input_blocked = false; } // namespace
void JI_DisableKeyboard(Uint32 time) {
wait_ms = static_cast<float>(time);
}
void JI_SetInputBlocked(bool blocked) { void JI_SetInputBlocked(bool blocked) {
input_blocked = blocked; input_blocked = blocked;
} }
static Uint8 virtual_keystates[JI_VSRC_COUNT][SDL_SCANCODE_COUNT] = {{0}};
void JI_SetVirtualKey(int scancode, int source, bool pressed) { void JI_SetVirtualKey(int scancode, int source, bool pressed) {
if (scancode < 0 || scancode >= SDL_SCANCODE_COUNT) return; if (scancode < 0 || scancode >= SDL_SCANCODE_COUNT) return;
if (source < 0 || source >= JI_VSRC_COUNT) return; if (source < 0 || source >= JI_VSRC_COUNT) return;
virtual_keystates[source][scancode] = pressed ? 1 : 0; virtual_keystates[source][scancode] = pressed ? 1 : 0;
} }
void JI_moveCheats(Uint8 new_key) { void JI_moveCheats(Uint8 scancode) {
cheat[0] = cheat[1]; cheat[0] = cheat[1];
cheat[1] = cheat[2]; cheat[1] = cheat[2];
cheat[2] = cheat[3]; cheat[2] = cheat[3];
cheat[3] = cheat[4]; cheat[3] = cheat[4];
cheat[4] = new_key; cheat[4] = scancode_to_ascii(scancode);
} }
void JI_Update() { void JI_Update() {
@@ -43,14 +67,22 @@ void JI_Update() {
keystates = SDL_GetKeyboardState(NULL); keystates = SDL_GetKeyboardState(NULL);
} }
if (waitTime > 0) waitTime--; const Uint64 now = SDL_GetTicks();
if (last_update_tick == 0) last_update_tick = now;
const float delta_ms = static_cast<float>(now - last_update_tick);
last_update_tick = now;
if (wait_ms > 0.0f) {
wait_ms -= delta_ms;
if (wait_ms < 0.0f) wait_ms = 0.0f;
}
// Consumim el flag de "alguna tecla no-GUI polsada" del director // Consumim el flag de "alguna tecla no-GUI polsada" del director
key_pressed = Director::get()->consumeKeyPressed(); key_pressed = Director::get()->consumeKeyPressed();
} }
bool JI_KeyPressed(int key) { bool JI_KeyPressed(int key) {
if (waitTime > 0 || keystates == nullptr) return false; if (wait_ms > 0.0f || keystates == nullptr) return false;
// Input bloquejat (p.ex. menú flotant obert) // Input bloquejat (p.ex. menú flotant obert)
if (input_blocked) return false; if (input_blocked) return false;
// ESC bloquejada pel Director (primera pulsació mostra notificació) // ESC bloquejada pel Director (primera pulsació mostra notificació)
@@ -64,13 +96,17 @@ bool JI_KeyPressed(int key) {
} }
bool JI_CheatActivated(const char* cheat_code) { bool JI_CheatActivated(const char* cheat_code) {
bool found = true; const size_t len = std::strlen(cheat_code);
for (size_t i = 0; i < strlen(cheat_code); i++) { if (len > sizeof(cheat)) return false;
if (cheat[i] != cheat_code[i]) found = false; // Compara contra els últims `len` caràcters del buffer. El buffer té
// mida fixa 5 i acumula sempre el darrer tecle a la posició 4.
const size_t offset = sizeof(cheat) - len;
for (size_t i = 0; i < len; i++) {
if (cheat[offset + i] != static_cast<Uint8>(cheat_code[i])) return false;
} }
return found; return true;
} }
bool JI_AnyKey() { bool JI_AnyKey() {
return waitTime > 0 ? false : key_pressed; return wait_ms > 0.0f ? false : key_pressed;
} }

View File

@@ -4,7 +4,7 @@
#include <string> #include <string>
#include <unordered_map> #include <unordered_map>
#include "core/jail/jfile.hpp" #include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp" #include "external/fkyaml_node.hpp"
namespace Locale { namespace Locale {
@@ -27,14 +27,12 @@ namespace Locale {
} }
bool load(const char* filename) { bool load(const char* filename) {
int size = 0; auto buffer = ResourceHelper::loadFile(filename);
char* buffer = file_getfilebuffer(filename, size, true); if (buffer.empty()) {
if (!buffer || size <= 0) {
std::cerr << "Locale: unable to load " << filename << '\n'; std::cerr << "Locale: unable to load " << filename << '\n';
return false; return false;
} }
std::string content(buffer, size); std::string content(reinterpret_cast<const char*>(buffer.data()), buffer.size());
free(buffer);
try { try {
auto yaml = fkyaml::node::deserialize(content); auto yaml = fkyaml::node::deserialize(content);

View File

@@ -1,17 +1,22 @@
#include "core/rendering/menu.hpp" #include "core/rendering/menu.hpp"
#include <cmath>
#include <cstdio> #include <cstdio>
#include <functional> #include <functional>
#include <memory> #include <memory>
#include <string> #include <string>
#include <vector> #include <vector>
#include "core/input/key_config.hpp"
#include "core/locale/locale.hpp" #include "core/locale/locale.hpp"
#include "core/rendering/overlay.hpp" #include "core/rendering/overlay.hpp"
#include "core/rendering/screen.hpp" #include "core/rendering/screen.hpp"
#include "core/rendering/text.hpp" #include "core/rendering/text.hpp"
#include "core/system/director.hpp"
#include "game/defines.hpp"
#include "game/options.hpp" #include "game/options.hpp"
#include "utils/easing.hpp" #include "utils/easing.hpp"
#include "version.h"
namespace Menu { namespace Menu {
@@ -35,38 +40,60 @@ namespace Menu {
static constexpr int ITEM_SPACING = 11; static constexpr int ITEM_SPACING = 11;
static constexpr int BOTTOM_PAD = 6; static constexpr int BOTTOM_PAD = 6;
static constexpr int HEADER_H = TITLE_PAD_Y + 8 /*charH*/ + 2 + 4; // títol + línia + gap static constexpr int HEADER_H = TITLE_PAD_Y + 8 /*charH*/ + 2 + 4; // títol + línia + gap
static constexpr int SUBTITLE_H = 8 + 3; // línia de subtítol + gap
// --- Animació --- // --- Animació ---
static constexpr float OPEN_SPEED = 8.0F; // 1.0 / 0.125s static constexpr float OPEN_SPEED = 8.0F; // 1.0 / 0.125s
static constexpr float CLOSE_SPEED = 10.0F; // 1.0 / 0.1s (una mica més ràpida que l'obertura)
static constexpr float HEIGHT_RATE = 12.0F; // smoothing exponencial de l'alçada (~150 ms al 90%)
// --- Items --- // --- Items ---
enum class ItemKind { Toggle, enum class ItemKind { Toggle,
Cycle, Cycle,
IntRange, IntRange,
Submenu, Submenu,
KeyBind }; KeyBind,
Action };
struct Item { struct Item {
const char* label; const char* label;
ItemKind kind; ItemKind kind;
std::function<std::string()> getValue; // opcional std::function<std::string()> getValue; // opcional
std::function<void(int dir)> change; // per Toggle/Cycle/IntRange std::function<void(int dir)> change; // per Toggle/Cycle/IntRange
std::function<void()> enter; // per Submenu std::function<void()> enter; // per Submenu i Action
SDL_Scancode* scancode{nullptr}; // per KeyBind SDL_Scancode* scancode{nullptr}; // per KeyBind
std::function<bool()> visible; // nullptr ⇒ sempre visible
}; };
struct Page { struct Page {
const char* title; const char* title;
std::vector<Item> items; std::vector<Item> items;
int cursor{0}; int cursor{0};
std::string subtitle; // opcional — si no buit, es dibuixa sota el títol
}; };
static bool isVisible(const Item& it) { return !it.visible || it.visible(); }
// Troba el pròxim ítem visible en direcció `dir` (±1) a partir de `from`.
// Si cap és visible retorna `from`.
static int nextVisibleCursor(const Page& p, int from, int dir) {
const int n = static_cast<int>(p.items.size());
if (n <= 0) return from;
for (int i = 1; i <= n; ++i) {
int idx = ((from + dir * i) % n + n) % n;
if (isVisible(p.items[idx])) return idx;
}
return from;
}
// --- Estat --- // --- Estat ---
static std::vector<Page> stack_; static std::vector<Page> stack_;
static std::unique_ptr<Text> font_; static std::unique_ptr<Text> font_;
static float open_anim_{0.0F}; // 0 = tancat, 1 = obert static float open_anim_{0.0F}; // 0 = tancat, 1 = obert
static float animated_h_{0.0F}; // alçada actual animada (smoothing cap al target visible)
static Uint32 last_ticks_{0}; static Uint32 last_ticks_{0};
static SDL_Scancode* capturing_{nullptr}; // != null → esperant tecla per assignar static SDL_Scancode* capturing_{nullptr}; // != null → esperant tecla per assignar
static bool closing_{false}; // true mentre l'animació de tancament és en curs
// --- Transició entre pàgines --- // --- Transició entre pàgines ---
static constexpr float TRANSITION_SPEED = 5.5F; // ~180 ms static constexpr float TRANSITION_SPEED = 5.5F; // ~180 ms
@@ -102,6 +129,7 @@ namespace Menu {
static Page buildAudio(); static Page buildAudio();
static Page buildControls(); static Page buildControls();
static Page buildGame(); static Page buildGame();
static Page buildSystem();
static Page buildRoot() { static Page buildRoot() {
Page p{Locale::get("menu.titles.root"), {}, 0}; Page p{Locale::get("menu.titles.root"), {}, 0};
@@ -109,50 +137,77 @@ namespace Menu {
p.items.push_back({Locale::get("menu.items.audio"), ItemKind::Submenu, nullptr, nullptr, [] { pushPage(buildAudio()); }, nullptr}); p.items.push_back({Locale::get("menu.items.audio"), ItemKind::Submenu, nullptr, nullptr, [] { pushPage(buildAudio()); }, nullptr});
p.items.push_back({Locale::get("menu.items.controls"), ItemKind::Submenu, nullptr, nullptr, [] { pushPage(buildControls()); }, nullptr}); p.items.push_back({Locale::get("menu.items.controls"), ItemKind::Submenu, nullptr, nullptr, [] { pushPage(buildControls()); }, nullptr});
p.items.push_back({Locale::get("menu.items.game"), ItemKind::Submenu, nullptr, nullptr, [] { pushPage(buildGame()); }, nullptr}); p.items.push_back({Locale::get("menu.items.game"), ItemKind::Submenu, nullptr, nullptr, [] { pushPage(buildGame()); }, nullptr});
p.items.push_back({Locale::get("menu.items.system"), ItemKind::Submenu, nullptr, nullptr, [] { pushPage(buildSystem()); }, nullptr});
return p; return p;
} }
static Page buildVideo() { static Page buildVideo() {
Page p{Locale::get("menu.titles.video"), {}, 0}; Page p{Locale::get("menu.titles.video"), {}, 0};
// Zoom i fullscreen: sense sentit a WASM (el navegador posseix el canvas)
#ifndef __EMSCRIPTEN__
p.items.push_back({Locale::get("menu.items.zoom"), ItemKind::IntRange, [] { p.items.push_back({Locale::get("menu.items.zoom"), ItemKind::IntRange, [] {
char buf[16]; char buf[16];
std::snprintf(buf, sizeof(buf), "%dX", Screen::get()->getZoom()); std::snprintf(buf, sizeof(buf), "%dX", Screen::get()->getZoom());
return std::string(buf); }, [](int dir) { return std::string(buf); }, [](int dir) {
if (dir < 0) Screen::get()->decZoom(); if (dir < 0) Screen::get()->decZoom();
else if (dir > 0) Screen::get()->incZoom(); }, nullptr}); else if (dir > 0) Screen::get()->incZoom(); }, nullptr, nullptr});
p.items.push_back({Locale::get("menu.items.screen"), ItemKind::Toggle, [] { return std::string(Screen::get()->isFullscreen() ? Locale::get("menu.values.fullscreen") : Locale::get("menu.values.windowed")); }, [](int) { Screen::get()->toggleFullscreen(); }, nullptr}); p.items.push_back({Locale::get("menu.items.screen"), ItemKind::Toggle, [] { return std::string(Screen::get()->isFullscreen() ? Locale::get("menu.values.fullscreen") : Locale::get("menu.values.windowed")); }, [](int) { Screen::get()->toggleFullscreen(); }, nullptr, nullptr, nullptr});
#endif
p.items.push_back({Locale::get("menu.items.shader"), ItemKind::Toggle, [] { return onOff(Options::video.shader_enabled); }, [](int) { Screen::get()->toggleShaders(); }, nullptr}); // Opcions visuals generals (sempre visibles)
p.items.push_back({Locale::get("menu.items.aspect_4_3"), ItemKind::Toggle, [] { return yesNo(Options::video.aspect_ratio_4_3); }, [](int) { Screen::get()->toggleAspectRatio(); }, nullptr, nullptr, nullptr});
p.items.push_back({Locale::get("menu.items.aspect_4_3"), ItemKind::Toggle, [] { return yesNo(Options::video.aspect_ratio_4_3); }, [](int) { Screen::get()->toggleAspectRatio(); }, nullptr}); p.items.push_back({Locale::get("menu.items.vsync"), ItemKind::Toggle, [] { return onOff(Options::video.vsync); }, [](int) { Screen::get()->toggleVSync(); }, nullptr, nullptr, nullptr});
p.items.push_back({Locale::get("menu.items.supersampling"), ItemKind::Toggle, [] { return onOff(Options::video.supersampling); }, [](int) { Screen::get()->toggleSupersampling(); }, nullptr}); p.items.push_back({Locale::get("menu.items.scaling_mode"), ItemKind::Cycle, [] {
switch (Options::video.scaling_mode) {
case Options::ScalingMode::DISABLED: return std::string(Locale::get("menu.values.scaling_disabled"));
case Options::ScalingMode::STRETCH: return std::string(Locale::get("menu.values.scaling_stretch"));
case Options::ScalingMode::LETTERBOX: return std::string(Locale::get("menu.values.scaling_letterbox"));
case Options::ScalingMode::OVERSCAN: return std::string(Locale::get("menu.values.scaling_overscan"));
case Options::ScalingMode::INTEGER: return std::string(Locale::get("menu.values.scaling_integer"));
}
return std::string(Locale::get("menu.values.scaling_integer")); }, [](int dir) { Screen::get()->cycleScalingMode(dir); }, nullptr, nullptr, nullptr});
p.items.push_back({Locale::get("menu.items.vsync"), ItemKind::Toggle, [] { return onOff(Options::video.vsync); }, [](int) { Screen::get()->toggleVSync(); }, nullptr}); p.items.push_back({Locale::get("menu.items.texture_filter"), ItemKind::Cycle, [] { return std::string(Options::video.texture_filter == Options::TextureFilter::LINEAR
? Locale::get("menu.values.linear")
: Locale::get("menu.values.nearest")); }, [](int dir) { Screen::get()->cycleTextureFilter(dir); }, nullptr, nullptr, nullptr});
p.items.push_back({Locale::get("menu.items.integer_scale"), ItemKind::Toggle, [] { return onOff(Options::video.integer_scale); }, [](int) { Screen::get()->toggleIntegerScale(); }, nullptr}); p.items.push_back({Locale::get("menu.items.internal_resolution"), ItemKind::IntRange, [] {
char buf[16];
std::snprintf(buf, sizeof(buf), "%dX", Options::video.internal_resolution);
return std::string(buf); }, [](int dir) { Screen::get()->changeInternalResolution(dir); }, nullptr, nullptr, nullptr});
// Bloc shaders: no disponible a WASM (NO_SHADERS, sense SDL3 GPU a WebGL2)
#ifndef __EMSCRIPTEN__
p.items.push_back({Locale::get("menu.items.shader"), ItemKind::Toggle, [] { return onOff(Options::video.shader_enabled); }, [](int) { Screen::get()->toggleShaders(); }, nullptr, nullptr, nullptr});
p.items.push_back({Locale::get("menu.items.shader_type"), ItemKind::Cycle, [] { return std::string(Screen::get()->getActiveShaderName()); }, [](int dir) { p.items.push_back({Locale::get("menu.items.shader_type"), ItemKind::Cycle, [] { return std::string(Screen::get()->getActiveShaderName()); }, [](int dir) {
if (dir < 0) Screen::get()->prevShaderType(); if (dir < 0) Screen::get()->prevShaderType();
else Screen::get()->nextShaderType(); }, nullptr}); else Screen::get()->nextShaderType(); }, nullptr, nullptr, [] { return Options::video.shader_enabled; }});
p.items.push_back({Locale::get("menu.items.preset"), ItemKind::Cycle, [] { return std::string(Screen::get()->getCurrentPresetName()); }, [](int dir) { p.items.push_back({Locale::get("menu.items.preset"), ItemKind::Cycle, [] { return std::string(Screen::get()->getCurrentPresetName()); }, [](int dir) {
if (dir < 0) Screen::get()->prevPreset(); if (dir < 0) Screen::get()->prevPreset();
else Screen::get()->nextPreset(); }, nullptr}); else Screen::get()->nextPreset(); }, nullptr, nullptr, [] { return Options::video.shader_enabled; }});
p.items.push_back({Locale::get("menu.items.stretch_filter"), ItemKind::Toggle, [] { return std::string(Options::video.stretch_filter_linear ? Locale::get("menu.values.linear") : Locale::get("menu.values.nearest")); }, [](int) { Screen::get()->toggleStretchFilter(); }, nullptr}); p.items.push_back({Locale::get("menu.items.supersampling"), ItemKind::Toggle, [] { return onOff(Options::video.supersampling); }, [](int) { Screen::get()->toggleSupersampling(); }, nullptr, nullptr, [] {
if (!Options::video.shader_enabled) return false;
const char* name = Screen::get()->getActiveShaderName();
return name && std::string(name) == "POSTFX"; }});
#endif
// Informació de render
p.items.push_back({Locale::get("menu.items.render_info"), ItemKind::Cycle, [] { p.items.push_back({Locale::get("menu.items.render_info"), ItemKind::Cycle, [] {
switch (Options::render_info.position) { switch (Options::render_info.position) {
case Options::RenderInfoPosition::OFF: return std::string(Locale::get("menu.values.off")); case Options::RenderInfoPosition::OFF: return std::string(Locale::get("menu.values.off"));
case Options::RenderInfoPosition::TOP: return std::string(Locale::get("menu.values.top")); case Options::RenderInfoPosition::TOP: return std::string(Locale::get("menu.values.top"));
case Options::RenderInfoPosition::BOTTOM: return std::string(Locale::get("menu.values.bottom")); case Options::RenderInfoPosition::BOTTOM: return std::string(Locale::get("menu.values.bottom"));
} }
return std::string(Locale::get("menu.values.off")); }, [](int dir) { Overlay::cycleRenderInfo(dir); }, nullptr}); return std::string(Locale::get("menu.values.off")); }, [](int dir) { Overlay::cycleRenderInfo(dir); }, nullptr, nullptr, nullptr});
p.items.push_back({Locale::get("menu.items.uptime"), ItemKind::Toggle, [] { return onOff(Options::render_info.show_time); }, [](int) { Options::render_info.show_time = !Options::render_info.show_time; }, nullptr}); p.items.push_back({Locale::get("menu.items.uptime"), ItemKind::Toggle, [] { return onOff(Options::render_info.show_time); }, [](int) { Options::render_info.show_time = !Options::render_info.show_time; }, nullptr, nullptr, [] { return Options::render_info.position != Options::RenderInfoPosition::OFF; }});
return p; return p;
} }
@@ -181,7 +236,7 @@ namespace Menu {
p.items.push_back({Locale::get("menu.items.move_down"), ItemKind::KeyBind, nullptr, nullptr, nullptr, &Options::keys_game.down}); p.items.push_back({Locale::get("menu.items.move_down"), ItemKind::KeyBind, nullptr, nullptr, nullptr, &Options::keys_game.down});
p.items.push_back({Locale::get("menu.items.move_left"), ItemKind::KeyBind, nullptr, nullptr, nullptr, &Options::keys_game.left}); p.items.push_back({Locale::get("menu.items.move_left"), ItemKind::KeyBind, nullptr, nullptr, nullptr, &Options::keys_game.left});
p.items.push_back({Locale::get("menu.items.move_right"), ItemKind::KeyBind, nullptr, nullptr, nullptr, &Options::keys_game.right}); p.items.push_back({Locale::get("menu.items.move_right"), ItemKind::KeyBind, nullptr, nullptr, nullptr, &Options::keys_game.right});
p.items.push_back({Locale::get("menu.items.menu_key"), ItemKind::KeyBind, nullptr, nullptr, nullptr, &Options::keys_gui.menu_toggle}); p.items.push_back({Locale::get("menu.items.menu_key"), ItemKind::KeyBind, nullptr, nullptr, nullptr, KeyConfig::scancodePtr("menu_toggle")});
return p; return p;
} }
@@ -194,17 +249,17 @@ namespace Menu {
p.items.push_back({Locale::get("menu.items.master_volume"), ItemKind::IntRange, [] { return volPct(Options::audio.volume); }, [](int dir) { stepVolume(Options::audio.volume, dir); }, nullptr}); p.items.push_back({Locale::get("menu.items.master_volume"), ItemKind::IntRange, [] { return volPct(Options::audio.volume); }, [](int dir) { stepVolume(Options::audio.volume, dir); }, nullptr});
p.items.push_back({Locale::get("menu.items.music"), ItemKind::Toggle, [] { return onOff(Options::audio.music_enabled); }, [](int) { p.items.push_back({Locale::get("menu.items.music"), ItemKind::Toggle, [] { return onOff(Options::audio.music.enabled); }, [](int) {
Options::audio.music_enabled = !Options::audio.music_enabled; Options::audio.music.enabled = !Options::audio.music.enabled;
Options::applyAudio(); }, nullptr}); Options::applyAudio(); }, nullptr});
p.items.push_back({Locale::get("menu.items.music_volume"), ItemKind::IntRange, [] { return volPct(Options::audio.music_volume); }, [](int dir) { stepVolume(Options::audio.music_volume, dir); }, nullptr}); p.items.push_back({Locale::get("menu.items.music_volume"), ItemKind::IntRange, [] { return volPct(Options::audio.music.volume); }, [](int dir) { stepVolume(Options::audio.music.volume, dir); }, nullptr});
p.items.push_back({Locale::get("menu.items.sounds"), ItemKind::Toggle, [] { return onOff(Options::audio.sound_enabled); }, [](int) { p.items.push_back({Locale::get("menu.items.sounds"), ItemKind::Toggle, [] { return onOff(Options::audio.sound.enabled); }, [](int) {
Options::audio.sound_enabled = !Options::audio.sound_enabled; Options::audio.sound.enabled = !Options::audio.sound.enabled;
Options::applyAudio(); }, nullptr}); Options::applyAudio(); }, nullptr});
p.items.push_back({Locale::get("menu.items.sounds_volume"), ItemKind::IntRange, [] { return volPct(Options::audio.sound_volume); }, [](int dir) { stepVolume(Options::audio.sound_volume, dir); }, nullptr}); p.items.push_back({Locale::get("menu.items.sounds_volume"), ItemKind::IntRange, [] { return volPct(Options::audio.sound.volume); }, [](int dir) { stepVolume(Options::audio.sound.volume, dir); }, nullptr});
return p; return p;
} }
@@ -216,6 +271,29 @@ namespace Menu {
p.items.push_back({Locale::get("menu.items.show_title_credits"), ItemKind::Toggle, [] { return yesNo(Options::game.show_title_credits); }, [](int) { Options::game.show_title_credits = !Options::game.show_title_credits; }, nullptr}); p.items.push_back({Locale::get("menu.items.show_title_credits"), ItemKind::Toggle, [] { return yesNo(Options::game.show_title_credits); }, [](int) { Options::game.show_title_credits = !Options::game.show_title_credits; }, nullptr});
p.items.push_back({Locale::get("menu.items.show_preload"), ItemKind::Toggle, [] { return yesNo(Options::game.show_preload); }, [](int) { Options::game.show_preload = !Options::game.show_preload; }, nullptr});
return p;
}
static Page buildSystem() {
Page p{Locale::get("menu.titles.system"), {}, 0};
p.subtitle = std::string("v") + Texts::VERSION + " (" + Version::GIT_HASH + ")";
p.items.push_back({Locale::get("menu.items.restart"), ItemKind::Action, nullptr, nullptr, [] {
if (Director::get()) Director::get()->requestRestart();
},
nullptr,
nullptr});
#ifndef __EMSCRIPTEN__
p.items.push_back({Locale::get("menu.items.exit_game"), ItemKind::Action, nullptr, nullptr, [] {
if (Director::get()) Director::get()->requestQuit();
},
nullptr,
nullptr});
#endif
return p; return p;
} }
@@ -262,11 +340,17 @@ namespace Menu {
fillRect(buf, x + w - 1, y, 1, h, color); fillRect(buf, x + w - 1, y, 1, h, color);
} }
// Mida final de la caixa segons el nombre d'items // Mida final de la caixa segons el nombre d'items *visibles*.
// body = (N-1) * ITEM_SPACING + charH — així BOTTOM_PAD és el buit real
// sota el text del darrer ítem, no un buit extra per sobre d'un "slot" buit.
static int boxHeight(const Page& page) { static int boxHeight(const Page& page) {
int n = static_cast<int>(page.items.size()); int n = 0;
int body = (n == 0) ? ITEM_SPACING : n * ITEM_SPACING; for (const auto& it : page.items) {
return HEADER_H + body + BOTTOM_PAD; if (isVisible(it)) ++n;
}
int body = (n == 0) ? 8 : (n - 1) * ITEM_SPACING + 8;
int header = HEADER_H + (page.subtitle.empty() ? 0 : SUBTITLE_H);
return header + body + BOTTOM_PAD;
} }
// --- API pública --- // --- API pública ---
@@ -275,34 +359,56 @@ namespace Menu {
font_ = std::make_unique<Text>("fonts/8bithud.fnt", "fonts/8bithud.gif"); font_ = std::make_unique<Text>("fonts/8bithud.fnt", "fonts/8bithud.gif");
stack_.clear(); stack_.clear();
open_anim_ = 0.0F; open_anim_ = 0.0F;
closing_ = false;
last_ticks_ = SDL_GetTicks(); last_ticks_ = SDL_GetTicks();
} }
void destroy() { void destroy() {
font_.reset(); font_.reset();
stack_.clear(); stack_.clear();
closing_ = false;
} }
// "Actiu": accepta input. Durant l'animació de tancament la pila encara
// té pàgines però ja no ha de processar tecles.
auto isOpen() -> bool { auto isOpen() -> bool {
return !stack_.empty() && !closing_;
}
// "Visible": encara hi ha caixa per pintar (incloent close animation).
auto isVisible() -> bool {
return !stack_.empty(); return !stack_.empty();
} }
void toggle() { void toggle() {
if (closing_ && !stack_.empty()) {
// Cancel·la el tancament en curs — continua l'animació cap a "obert"
// des del valor actual d'open_anim_.
closing_ = false;
last_ticks_ = SDL_GetTicks();
return;
}
if (isOpen()) { if (isOpen()) {
close(); close();
} else { } else {
stack_.push_back(buildRoot()); stack_.push_back(buildRoot());
open_anim_ = 0.0F; open_anim_ = 0.0F;
closing_ = false;
animated_h_ = static_cast<float>(boxHeight(stack_.back()));
last_ticks_ = SDL_GetTicks(); last_ticks_ = SDL_GetTicks();
} }
} }
// close() no buida la pila immediatament: marca closing_ i deixa que
// render() faça decréixer open_anim_ fins a 0. En aquell moment es neteja
// l'estat. Si es crida estant ja tancat o tancant-se, no-op.
void close() { void close() {
stack_.clear(); if (stack_.empty() || closing_) return;
open_anim_ = 0.0F; closing_ = true;
capturing_ = nullptr; capturing_ = nullptr;
transition_active_ = false; transition_active_ = false;
transition_progress_ = 1.0F; transition_progress_ = 1.0F;
last_ticks_ = SDL_GetTicks();
} }
auto isCapturing() -> bool { auto isCapturing() -> bool {
@@ -333,13 +439,17 @@ namespace Menu {
} }
return; return;
} }
const int n = static_cast<int>(page.items.size()); // Si el cursor està sobre un ítem ocultat (p. ex. una acció anterior el va ocultar),
// reubica'l al pròxim visible abans de processar l'entrada.
if (!isVisible(page.items[page.cursor])) {
page.cursor = nextVisibleCursor(page, page.cursor, +1);
}
switch (sc) { switch (sc) {
case SDL_SCANCODE_UP: case SDL_SCANCODE_UP:
page.cursor = (page.cursor - 1 + n) % n; page.cursor = nextVisibleCursor(page, page.cursor, -1);
break; break;
case SDL_SCANCODE_DOWN: case SDL_SCANCODE_DOWN:
page.cursor = (page.cursor + 1) % n; page.cursor = nextVisibleCursor(page, page.cursor, +1);
break; break;
case SDL_SCANCODE_LEFT: case SDL_SCANCODE_LEFT:
if (page.items[page.cursor].kind != ItemKind::Submenu && if (page.items[page.cursor].kind != ItemKind::Submenu &&
@@ -355,7 +465,8 @@ namespace Menu {
break; break;
case SDL_SCANCODE_RETURN: case SDL_SCANCODE_RETURN:
case SDL_SCANCODE_KP_ENTER: case SDL_SCANCODE_KP_ENTER:
if (page.items[page.cursor].kind == ItemKind::Submenu) { if (page.items[page.cursor].kind == ItemKind::Submenu ||
page.items[page.cursor].kind == ItemKind::Action) {
if (page.items[page.cursor].enter) page.items[page.cursor].enter(); if (page.items[page.cursor].enter) page.items[page.cursor].enter();
} else if (page.items[page.cursor].kind == ItemKind::KeyBind) { } else if (page.items[page.cursor].kind == ItemKind::KeyBind) {
capturing_ = page.items[page.cursor].scancode; capturing_ = page.items[page.cursor].scancode;
@@ -372,6 +483,15 @@ namespace Menu {
default: default:
break; break;
} }
// Després de qualsevol acció, si el cursor quedara sobre un ítem ocult
// (possible si una acció ha canviat la visibilitat pròpia de l'ítem actual,
// edge case defensiu), salta al següent visible.
if (!stack_.empty()) {
Page& top = stack_.back();
if (!top.items.empty() && !isVisible(top.items[top.cursor])) {
top.cursor = nextVisibleCursor(top, top.cursor, +1);
}
}
} }
// Dibuixa el contingut d'una pàgina amb un offset horitzontal i un rang de clip. // Dibuixa el contingut d'una pàgina amb un offset horitzontal i un rang de clip.
@@ -395,25 +515,49 @@ namespace Menu {
} }
} }
// Items o placeholder buit // Subtítol opcional (sota la línia del títol, abans dels items)
int items_y = title_line_y + 4; int items_y = title_line_y + 4;
if (page.items.empty()) { if (!page.subtitle.empty()) {
int sub_w = font_->width(page.subtitle.c_str());
int sub_x = box_x + (BOX_W - sub_w) / 2 + x_offset;
font_->drawClipped(pixel_data, sub_x, items_y, page.subtitle.c_str(), LABEL_COLOR, clip_x_min, clip_x_max, clip_y_min, clip_y_max);
items_y += SUBTITLE_H;
}
// Compta visibles — si cap, dibuixa placeholder (caixa totalment col·lapsada però oberta)
int visible_count = 0;
for (const auto& it : page.items)
if (isVisible(it)) ++visible_count;
if (visible_count == 0) {
const char* empty_text = Locale::get("menu.values.empty"); const char* empty_text = Locale::get("menu.values.empty");
int ew = font_->width(empty_text); int ew = font_->width(empty_text);
font_->drawClipped(pixel_data, box_x + (BOX_W - ew) / 2 + x_offset, items_y + 2, empty_text, EMPTY_COLOR, clip_x_min, clip_x_max, clip_y_min, clip_y_max); font_->drawClipped(pixel_data, box_x + (BOX_W - ew) / 2 + x_offset, items_y + 2, empty_text, EMPTY_COLOR, clip_x_min, clip_x_max, clip_y_min, clip_y_max);
return; return;
} }
int y_slot = 0; // índex de fila visible (independent de l'índex real de l'ítem)
for (size_t i = 0; i < page.items.size(); i++) { for (size_t i = 0; i < page.items.size(); i++) {
int y = items_y + static_cast<int>(i) * ITEM_SPACING;
bool selected = (static_cast<int>(i) == page.cursor);
const Item& item = page.items[i]; const Item& item = page.items[i];
if (!isVisible(item)) continue;
int y = items_y + y_slot * ITEM_SPACING;
++y_slot;
bool selected = (static_cast<int>(i) == page.cursor);
Uint32 label_color = selected ? CURSOR_COLOR : LABEL_COLOR;
// Action: sense valor a la dreta — centrem el label amb el cursor just a l'esquerra.
if (item.kind == ItemKind::Action) {
int lw = font_->width(item.label);
int lx = box_x + (BOX_W - lw) / 2 + x_offset;
if (selected) {
font_->drawClipped(pixel_data, lx - font_->width("> "), y, ">", CURSOR_COLOR, clip_x_min, clip_x_max, clip_y_min, clip_y_max);
}
font_->drawClipped(pixel_data, lx, y, item.label, label_color, clip_x_min, clip_x_max, clip_y_min, clip_y_max);
continue;
}
if (selected) { if (selected) {
font_->drawClipped(pixel_data, box_x + 4 + x_offset, y, ">", CURSOR_COLOR, clip_x_min, clip_x_max, clip_y_min, clip_y_max); font_->drawClipped(pixel_data, box_x + 4 + x_offset, y, ">", CURSOR_COLOR, clip_x_min, clip_x_max, clip_y_min, clip_y_max);
} }
Uint32 label_color = selected ? CURSOR_COLOR : LABEL_COLOR;
font_->drawClipped(pixel_data, box_x + ITEM_PAD_X + x_offset, y, item.label, label_color, clip_x_min, clip_x_max, clip_y_min, clip_y_max); font_->drawClipped(pixel_data, box_x + ITEM_PAD_X + x_offset, y, item.label, label_color, clip_x_min, clip_x_max, clip_y_min, clip_y_max);
if (item.kind == ItemKind::Submenu) { if (item.kind == ItemKind::Submenu) {
@@ -438,13 +582,23 @@ namespace Menu {
} }
void render(Uint32* pixel_data) { void render(Uint32* pixel_data) {
if (!isOpen() || !font_ || !pixel_data) return; if (!isVisible() || !font_ || !pixel_data) return;
// Delta time // Delta time
Uint32 now = SDL_GetTicks(); Uint32 now = SDL_GetTicks();
float dt = static_cast<float>(now - last_ticks_) / 1000.0F; float dt = static_cast<float>(now - last_ticks_) / 1000.0F;
last_ticks_ = now; last_ticks_ = now;
if (open_anim_ < 1.0F) { if (closing_) {
open_anim_ -= CLOSE_SPEED * dt;
if (open_anim_ <= 0.0F) {
// Animació de tancament completada — buida l'estat de veritat.
open_anim_ = 0.0F;
stack_.clear();
animated_h_ = 0.0F;
closing_ = false;
return;
}
} else if (open_anim_ < 1.0F) {
open_anim_ += OPEN_SPEED * dt; open_anim_ += OPEN_SPEED * dt;
if (open_anim_ > 1.0F) open_anim_ = 1.0F; if (open_anim_ > 1.0F) open_anim_ = 1.0F;
} }
@@ -461,14 +615,30 @@ namespace Menu {
const Page& page = stack_.back(); const Page& page = stack_.back();
const int current_h = boxHeight(page); const int current_h = boxHeight(page);
// Smoothing exponencial de l'alçada cap al target (pàgina actual + ítems visibles).
// Permet que el menú reaccione amb animació quan una opció canvia la visibilitat
// d'altres ítems en calent (p. ex. shader=off → shader_type/preset/supersampling).
if (animated_h_ <= 0.0F) {
animated_h_ = static_cast<float>(current_h);
} else {
float diff = static_cast<float>(current_h) - animated_h_;
if (std::fabs(diff) < 0.5F) {
animated_h_ = static_cast<float>(current_h);
} else {
float t = HEIGHT_RATE * dt;
if (t > 1.0F) t = 1.0F;
animated_h_ += diff * t;
}
}
float eased = Easing::outQuad(open_anim_); float eased = Easing::outQuad(open_anim_);
// Calcula alçada (amb transició si escau) // Calcula alçada (amb transició si escau)
int target_h = current_h; int target_h = static_cast<int>(animated_h_);
if (transition_active_) { if (transition_active_) {
int outgoing_h = boxHeight(transition_outgoing_); int outgoing_h = boxHeight(transition_outgoing_);
float tp = Easing::outQuad(transition_progress_); float tp = Easing::outQuad(transition_progress_);
target_h = Easing::lerpInt(outgoing_h, current_h, tp); target_h = Easing::lerpInt(outgoing_h, static_cast<int>(animated_h_), tp);
} }
// Caixa creix verticalment durant l'obertura // Caixa creix verticalment durant l'obertura

View File

@@ -6,11 +6,15 @@ namespace Menu {
void init(); void init();
void destroy(); void destroy();
// "Actiu": el menú accepta input. Fals durant l'animació de tancament.
[[nodiscard]] auto isOpen() -> bool; [[nodiscard]] auto isOpen() -> bool;
// "Visible": hi ha una caixa pintada (incloent l'animació de tancament).
// Overlay la usa per a decidir si cridar render().
[[nodiscard]] auto isVisible() -> bool;
void toggle(); void toggle();
void close(); void close();
// Pinta el menú sobre el buffer ARGB — cridat des d'Overlay::render si està obert // Pinta el menú sobre el buffer ARGB — cridat des d'Overlay::render si està visible
void render(Uint32* pixel_data); void render(Uint32* pixel_data);
// Gestió d'input — cridat des del Director en KEY_DOWN // Gestió d'input — cridat des del Director en KEY_DOWN

View File

@@ -361,8 +361,8 @@ namespace Overlay {
std::remove_if(notifications_.begin(), notifications_.end(), [](const Notification& n) { return n.status == Status::FINISHED; }), std::remove_if(notifications_.begin(), notifications_.end(), [](const Notification& n) { return n.status == Status::FINISHED; }),
notifications_.end()); notifications_.end());
// Menú flotant per damunt de tot // Menú flotant per damunt de tot (isVisible inclou l'animació de tancament)
if (Menu::isOpen()) { if (Menu::isVisible()) {
Menu::render(pixel_data); Menu::render(pixel_data);
} }
} }

View File

@@ -5,24 +5,68 @@
#include "core/locale/locale.hpp" #include "core/locale/locale.hpp"
#include "core/rendering/overlay.hpp" #include "core/rendering/overlay.hpp"
#ifndef NO_SHADERS
#include "core/rendering/sdl3gpu/sdl3gpu_shader.hpp" #include "core/rendering/sdl3gpu/sdl3gpu_shader.hpp"
#endif
#include "game/defines.hpp" #include "game/defines.hpp"
#include "game/options.hpp" #include "game/options.hpp"
#include "utils/utils.hpp" #include "utils/utils.hpp"
Screen* Screen::instance_ = nullptr; #ifdef __EMSCRIPTEN__
#include <emscripten.h>
#include <emscripten/html5.h>
// --- Fix per a fullscreen/resize en Emscripten ---
//
// SDL3 + Emscripten no emet de forma fiable SDL_EVENT_WINDOW_LEAVE_FULLSCREEN
// (libsdl-org/SDL#13300) ni SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED /
// SDL_EVENT_DISPLAY_ORIENTATION (libsdl-org/SDL#11389). Quan l'usuari ix de
// fullscreen amb Esc o rota el mòbil, el canvas HTML torna al tamany correcte
// però l'estat intern de SDL creu que segueix en fullscreen amb la resolució
// anterior i el viewport queda desencuadrat.
//
// Solució: registrar callbacks natius d'Emscripten, diferir la feina un tick
// del event loop (el canvas encara no està estable en el moment del callback)
// i re-sincronitzar SDL cridant SDL_SetWindowFullscreen + applyFallbackPresentation.
// La crida interna a SDL_SetWindowFullscreen és la peça que realment fa
// resincronitzar l'estat intern de SDL — sense això la logical presentation
// no encaixa amb el canvas real.
namespace {
Screen* g_screen_instance = nullptr;
void deferredCanvasResize(void* /*userData*/) {
if (g_screen_instance != nullptr) {
g_screen_instance->handleCanvasResized();
}
}
EM_BOOL onEmFullscreenChange(int /*eventType*/, const EmscriptenFullscreenChangeEvent* event, void* /*userData*/) {
if (g_screen_instance != nullptr && event != nullptr) {
g_screen_instance->syncFullscreenFlagFromBrowser(event->isFullscreen != 0);
}
emscripten_async_call(deferredCanvasResize, nullptr, 0);
return EM_FALSE;
}
EM_BOOL onEmOrientationChange(int /*eventType*/, const EmscriptenOrientationChangeEvent* /*event*/, void* /*userData*/) {
emscripten_async_call(deferredCanvasResize, nullptr, 0);
return EM_FALSE;
}
} // namespace
#endif // __EMSCRIPTEN__
std::unique_ptr<Screen> Screen::instance_;
void Screen::init() { void Screen::init() {
instance_ = new Screen(); instance_ = std::unique_ptr<Screen>(new Screen());
} }
void Screen::destroy() { void Screen::destroy() {
delete instance_; instance_.reset();
instance_ = nullptr;
} }
auto Screen::get() -> Screen* { auto Screen::get() -> Screen* {
return instance_; return instance_.get();
} }
Screen::Screen() { Screen::Screen() {
@@ -35,40 +79,70 @@ Screen::Screen() {
if (zoom_ < 1) zoom_ = 1; if (zoom_ < 1) zoom_ = 1;
if (zoom_ > max_zoom_) zoom_ = max_zoom_; if (zoom_ > max_zoom_) zoom_ = max_zoom_;
// Clamp de la resolució interna a [1, max_zoom_]. Llegir del YAML i
// ajustar aquí és l'únic moment en què es fa — el menú re-clampa cada
// canvi. Si la pantalla és més petita que el valor desat (p.ex. canvi
// de monitor), baixem al màxim suportat.
if (Options::video.internal_resolution < 1) Options::video.internal_resolution = 1;
if (Options::video.internal_resolution > max_zoom_) Options::video.internal_resolution = max_zoom_;
int w = GAME_WIDTH * zoom_; int w = GAME_WIDTH * zoom_;
int h = Options::video.aspect_ratio_4_3 ? static_cast<int>(GAME_HEIGHT * 1.2F) * zoom_ : GAME_HEIGHT * zoom_; int h = Options::video.aspect_ratio_4_3 ? static_cast<int>(GAME_HEIGHT * 1.2F) * zoom_ : GAME_HEIGHT * zoom_;
window_ = SDL_CreateWindow(Locale::get("window.title"), w, h, fullscreen_ ? SDL_WINDOW_FULLSCREEN : 0); window_ = SDL_CreateWindow(Locale::get("window.title"), w, h, fullscreen_ ? SDL_WINDOW_FULLSCREEN : 0);
renderer_ = SDL_CreateRenderer(window_, nullptr); renderer_ = SDL_CreateRenderer(window_, nullptr);
SDL_SetRenderLogicalPresentation(renderer_, GAME_WIDTH, GAME_HEIGHT, SDL_LOGICAL_PRESENTATION_LETTERBOX);
texture_ = SDL_CreateTexture(renderer_, SDL_PIXELFORMAT_ABGR8888, SDL_TEXTUREACCESS_STREAMING, GAME_WIDTH, GAME_HEIGHT); texture_ = SDL_CreateTexture(renderer_, SDL_PIXELFORMAT_ABGR8888, SDL_TEXTUREACCESS_STREAMING, GAME_WIDTH, GAME_HEIGHT);
SDL_SetTextureScaleMode(texture_, SDL_SCALEMODE_NEAREST); applyFallbackPresentation();
// Inicialitza backend GPU si l'acceleració està activada // Inicialitza backend GPU si l'acceleració està activada
initShaders(); initShaders();
std::cout << "Screen initialized: " << w << "x" << h << " (zoom " << zoom_ << ", max " << max_zoom_ << ")\n"; std::cout << "Screen initialized: " << w << "x" << h << " (zoom " << zoom_ << ", max " << max_zoom_ << ")\n";
#ifdef __EMSCRIPTEN__
// IMPORTANT: NO registrem resize callback genèric. En mòbil, fer scroll
// fa que el navegador oculti/mostri la barra d'URL, disparant un resize
// del DOM per cada scroll. Això portaria a re-aplicar logical presentation
// per cada scroll i corrompria el viewport intern de SDL.
g_screen_instance = this;
emscripten_set_fullscreenchange_callback(EMSCRIPTEN_EVENT_TARGET_DOCUMENT, nullptr, EM_TRUE, onEmFullscreenChange);
emscripten_set_orientationchange_callback(nullptr, EM_TRUE, onEmOrientationChange);
#endif
} }
Screen::~Screen() { Screen::~Screen() {
#ifdef __EMSCRIPTEN__
g_screen_instance = nullptr;
#endif
// Guarda opcions abans de destruir // Guarda opcions abans de destruir
Options::window.zoom = zoom_; Options::window.zoom = zoom_;
Options::window.fullscreen = fullscreen_; Options::window.fullscreen = fullscreen_;
// Destrueix el backend GPU // Destrueix el backend GPU (només existeix si s'ha compilat amb shaders)
if (shader_backend_) { if (shader_backend_) {
#ifndef NO_SHADERS
auto* gpu = dynamic_cast<Rendering::SDL3GPUShader*>(shader_backend_.get()); auto* gpu = dynamic_cast<Rendering::SDL3GPUShader*>(shader_backend_.get());
if (gpu) gpu->destroy(); if (gpu) gpu->destroy();
#endif
shader_backend_.reset(); shader_backend_.reset();
} }
if (internal_texture_sdl_) SDL_DestroyTexture(internal_texture_sdl_);
if (texture_) SDL_DestroyTexture(texture_); if (texture_) SDL_DestroyTexture(texture_);
if (renderer_) SDL_DestroyRenderer(renderer_); if (renderer_) SDL_DestroyRenderer(renderer_);
if (window_) SDL_DestroyWindow(window_); if (window_) SDL_DestroyWindow(window_);
} }
void Screen::initShaders() { void Screen::initShaders() {
#ifdef NO_SHADERS
// Build sense shaders (p.ex. emscripten/WebGL2, on SDL3 GPU no està
// disponible). Es salta tota la inicialització — shader_backend_ es
// queda nul·lptr i tots els `if (shader_backend_)` del render path
// curtcircuiten cap al fallback SDL_Renderer.
return;
#else
if (!Options::video.gpu_acceleration) return; if (!Options::video.gpu_acceleration) return;
shader_backend_ = std::make_unique<Rendering::SDL3GPUShader>(); shader_backend_ = std::make_unique<Rendering::SDL3GPUShader>();
@@ -88,17 +162,18 @@ void Screen::initShaders() {
std::cout << "GPU driver: " << gpu_driver_ << '\n'; std::cout << "GPU driver: " << gpu_driver_ << '\n';
// Aplica opcions de vídeo // Aplica opcions de vídeo
shader_backend_->setScaleMode(Options::video.integer_scale); shader_backend_->setScalingMode(Options::video.scaling_mode);
shader_backend_->setVSync(Options::video.vsync); shader_backend_->setVSync(Options::video.vsync);
shader_backend_->setStretchFilter(Options::video.stretch_filter_linear); shader_backend_->setTextureFilter(Options::video.texture_filter);
shader_backend_->setStretch4_3(Options::video.aspect_ratio_4_3); shader_backend_->setStretch4_3(Options::video.aspect_ratio_4_3);
shader_backend_->setLinearUpscale(Options::video.linear_upscale);
shader_backend_->setDownscaleAlgo(Options::video.downscale_algo); shader_backend_->setDownscaleAlgo(Options::video.downscale_algo);
if (Options::video.supersampling) { if (Options::video.supersampling) {
shader_backend_->setOversample(3); shader_backend_->setOversample(3);
} }
shader_backend_->setInternalResolution(Options::video.internal_resolution);
// Resol el shader actiu des del config // Resol el shader actiu des del config
if (Options::video.current_shader == "crtpi") { if (Options::video.current_shader == "crtpi") {
shader_backend_->setActiveShader(Rendering::ShaderType::CRTPI); shader_backend_->setActiveShader(Rendering::ShaderType::CRTPI);
@@ -122,6 +197,7 @@ void Screen::initShaders() {
applyCurrentPostFXPreset(); applyCurrentPostFXPreset();
applyCurrentCrtPiPreset(); applyCurrentCrtPiPreset();
#endif
} }
void Screen::present(Uint32* pixel_data) { void Screen::present(Uint32* pixel_data) {
@@ -135,14 +211,62 @@ void Screen::present(Uint32* pixel_data) {
shader_backend_->uploadPixels(pixel_data, GAME_WIDTH, GAME_HEIGHT); shader_backend_->uploadPixels(pixel_data, GAME_WIDTH, GAME_HEIGHT);
shader_backend_->render(); shader_backend_->render();
} else if (shader_backend_ && shader_backend_->isHardwareAccelerated()) { } else if (shader_backend_ && shader_backend_->isHardwareAccelerated()) {
// GPU activa però shaders desactivats: renderitza net (sense efectes) // GPU activa però shaders desactivats: renderitza net (sense efectes).
// Força POSTFX amb params zerats — altrament, si l'actiu és CRTPI,
// els seus efectes (scanlines, curvatura) seguirien aplicant-se encara
// que shader_enabled sigui false. Restaurem l'actiu al final per a
// no trencar la selecció de l'usuari.
Rendering::PostFXParams clean{}; Rendering::PostFXParams clean{};
shader_backend_->setPostFXParams(clean); shader_backend_->setPostFXParams(clean);
const auto prev_shader = shader_backend_->getActiveShader();
if (prev_shader != Rendering::ShaderType::POSTFX) {
shader_backend_->setActiveShader(Rendering::ShaderType::POSTFX);
}
shader_backend_->uploadPixels(pixel_data, GAME_WIDTH, GAME_HEIGHT); shader_backend_->uploadPixels(pixel_data, GAME_WIDTH, GAME_HEIGHT);
shader_backend_->render(); shader_backend_->render();
if (prev_shader != Rendering::ShaderType::POSTFX) {
shader_backend_->setActiveShader(prev_shader);
}
} else { } else {
// Fallback SDL_Renderer // Fallback SDL_Renderer. A mult=1, flux directe original: logical
// presentation (setada per applyFallbackPresentation) + scale mode de
// texture_ segons l'opció. A mult>1, la còpia intermèdia crea la
// font ampliada (NN via GPU), i es presenta via logical presentation
// a la mida de la font intermèdia.
SDL_UpdateTexture(texture_, nullptr, pixel_data, GAME_WIDTH * sizeof(Uint32)); SDL_UpdateTexture(texture_, nullptr, pixel_data, GAME_WIDTH * sizeof(Uint32));
const int mult = Options::video.internal_resolution;
if (mult > 1) {
ensureFallbackInternalTexture();
if (internal_texture_sdl_ != nullptr) {
// Còpia NN a la textura intermèdia (mult·game). Sampler NN
// per construcció: volem píxels grans i nets.
SDL_SetTextureScaleMode(texture_, SDL_SCALEMODE_NEAREST);
SDL_SetRenderTarget(renderer_, internal_texture_sdl_);
SDL_RenderClear(renderer_);
SDL_RenderTexture(renderer_, texture_, nullptr, nullptr);
SDL_SetRenderTarget(renderer_, nullptr);
// Filtre global al pas final → finestra (via logical presentation
// que applyFallbackPresentation ja configura amb mida game·mult).
SDL_ScaleMode final_scale = (Options::video.texture_filter == Options::TextureFilter::LINEAR)
? SDL_SCALEMODE_LINEAR
: SDL_SCALEMODE_NEAREST;
SDL_SetTextureScaleMode(internal_texture_sdl_, final_scale);
SDL_RenderClear(renderer_);
SDL_RenderTexture(renderer_, internal_texture_sdl_, nullptr, nullptr);
SDL_RenderPresent(renderer_);
return;
}
// Si la creació de la textura intermèdia ha fallat, caiem al path normal.
}
// mult=1 (o fallback-del-fallback): texture_ directament. El scale mode
// el manté applyFallbackPresentation — però el re-apliquem per si la
// ruta mult>1 el va sobreescriure anteriorment.
SDL_ScaleMode direct_scale = (Options::video.texture_filter == Options::TextureFilter::LINEAR)
? SDL_SCALEMODE_LINEAR
: SDL_SCALEMODE_NEAREST;
SDL_SetTextureScaleMode(texture_, direct_scale);
SDL_RenderClear(renderer_); SDL_RenderClear(renderer_);
SDL_RenderTexture(renderer_, texture_, nullptr, nullptr); SDL_RenderTexture(renderer_, texture_, nullptr, nullptr);
SDL_RenderPresent(renderer_); SDL_RenderPresent(renderer_);
@@ -182,26 +306,40 @@ void Screen::toggleShaders() {
} }
} }
void Screen::toggleSupersampling() { auto Screen::toggleSupersampling() -> bool {
if (!shader_backend_ || !shader_backend_->isHardwareAccelerated()) return; // SS només té sentit amb shaders on i pipeline PostFX (el Lanczos downscale
// i el camí SS s'apliquen al pas de PostFX; CRTPI fa el seu propi
// submostreig intern i no usa aquesta via).
if (!shader_backend_ || !shader_backend_->isHardwareAccelerated()) return false;
if (!Options::video.shader_enabled) return false;
if (shader_backend_->getActiveShader() != Rendering::ShaderType::POSTFX) return false;
Options::video.supersampling = !Options::video.supersampling; Options::video.supersampling = !Options::video.supersampling;
shader_backend_->setOversample(Options::video.supersampling ? 3 : 1); shader_backend_->setOversample(Options::video.supersampling ? 3 : 1);
return true;
} }
void Screen::toggleAspectRatio() { void Screen::toggleAspectRatio() {
Options::video.aspect_ratio_4_3 = !Options::video.aspect_ratio_4_3; Options::video.aspect_ratio_4_3 = !Options::video.aspect_ratio_4_3;
if (shader_backend_) { if (shader_backend_) {
shader_backend_->setStretch4_3(Options::video.aspect_ratio_4_3); shader_backend_->setStretch4_3(Options::video.aspect_ratio_4_3);
} else {
applyFallbackPresentation();
} }
if (!fullscreen_) { if (!fullscreen_) {
adjustWindowSize(); adjustWindowSize();
} }
} }
void Screen::toggleIntegerScale() { void Screen::cycleScalingMode(int dir) {
Options::video.integer_scale = !Options::video.integer_scale; constexpr int N = 5; // DISABLED, STRETCH, LETTERBOX, OVERSCAN, INTEGER
int cur = static_cast<int>(Options::video.scaling_mode);
int step = (dir >= 0) ? 1 : -1;
cur = ((cur + step) % N + N) % N;
Options::video.scaling_mode = static_cast<Options::ScalingMode>(cur);
if (shader_backend_) { if (shader_backend_) {
shader_backend_->setScaleMode(Options::video.integer_scale); shader_backend_->setScalingMode(Options::video.scaling_mode);
} else {
applyFallbackPresentation();
} }
} }
@@ -212,15 +350,39 @@ void Screen::toggleVSync() {
} }
} }
void Screen::toggleStretchFilter() { void Screen::cycleTextureFilter(int dir) {
Options::video.stretch_filter_linear = !Options::video.stretch_filter_linear; // NEAREST <-> LINEAR (només 2 valors, dir no importa més enllà de canviar)
(void)dir;
Options::video.texture_filter =
(Options::video.texture_filter == Options::TextureFilter::LINEAR)
? Options::TextureFilter::NEAREST
: Options::TextureFilter::LINEAR;
if (shader_backend_) { if (shader_backend_) {
shader_backend_->setStretchFilter(Options::video.stretch_filter_linear); shader_backend_->setTextureFilter(Options::video.texture_filter);
} else {
applyFallbackPresentation();
} }
} }
void Screen::nextShaderType() { void Screen::changeInternalResolution(int dir) {
if (!shader_backend_ || !shader_backend_->isHardwareAccelerated()) return; int next = Options::video.internal_resolution + (dir >= 0 ? 1 : -1);
if (next < 1) next = 1;
if (next > max_zoom_) next = max_zoom_;
if (next == Options::video.internal_resolution) return;
Options::video.internal_resolution = next;
// Propaga al backend actiu. Al fallback path, la textura es recrea al
// pròxim present via ensureFallbackInternalTexture.
if (shader_backend_) {
shader_backend_->setInternalResolution(next);
} else {
applyFallbackPresentation();
}
}
auto Screen::nextShaderType() -> bool {
if (!shader_backend_ || !shader_backend_->isHardwareAccelerated()) return false;
if (!Options::video.shader_enabled) return false;
if (shader_backend_->getActiveShader() == Rendering::ShaderType::POSTFX) { if (shader_backend_->getActiveShader() == Rendering::ShaderType::POSTFX) {
shader_backend_->setActiveShader(Rendering::ShaderType::CRTPI); shader_backend_->setActiveShader(Rendering::ShaderType::CRTPI);
@@ -231,45 +393,50 @@ void Screen::nextShaderType() {
Options::video.current_shader = "postfx"; Options::video.current_shader = "postfx";
applyCurrentPostFXPreset(); applyCurrentPostFXPreset();
} }
return true;
} }
void Screen::nextPreset() { auto Screen::nextPreset() -> bool {
if (!shader_backend_ || !shader_backend_->isHardwareAccelerated()) return; if (!shader_backend_ || !shader_backend_->isHardwareAccelerated()) return false;
if (!Options::video.shader_enabled) return false;
if (shader_backend_->getActiveShader() == Rendering::ShaderType::POSTFX) { if (shader_backend_->getActiveShader() == Rendering::ShaderType::POSTFX) {
if (Options::postfx_presets.empty()) return; if (Options::postfx_presets.empty()) return false;
Options::current_postfx_preset = (Options::current_postfx_preset + 1) % static_cast<int>(Options::postfx_presets.size()); Options::current_postfx_preset = (Options::current_postfx_preset + 1) % static_cast<int>(Options::postfx_presets.size());
Options::video.current_postfx_preset = Options::postfx_presets[Options::current_postfx_preset].name; Options::video.current_postfx_preset = Options::postfx_presets[Options::current_postfx_preset].name;
applyCurrentPostFXPreset(); applyCurrentPostFXPreset();
} else { } else {
if (Options::crtpi_presets.empty()) return; if (Options::crtpi_presets.empty()) return false;
Options::current_crtpi_preset = (Options::current_crtpi_preset + 1) % static_cast<int>(Options::crtpi_presets.size()); Options::current_crtpi_preset = (Options::current_crtpi_preset + 1) % static_cast<int>(Options::crtpi_presets.size());
Options::video.current_crtpi_preset = Options::crtpi_presets[Options::current_crtpi_preset].name; Options::video.current_crtpi_preset = Options::crtpi_presets[Options::current_crtpi_preset].name;
applyCurrentCrtPiPreset(); applyCurrentCrtPiPreset();
} }
return true;
} }
void Screen::prevShaderType() { auto Screen::prevShaderType() -> bool {
// Només dues opcions — prev == next // Només dues opcions — prev == next
nextShaderType(); return nextShaderType();
} }
void Screen::prevPreset() { auto Screen::prevPreset() -> bool {
if (!shader_backend_ || !shader_backend_->isHardwareAccelerated()) return; if (!shader_backend_ || !shader_backend_->isHardwareAccelerated()) return false;
if (!Options::video.shader_enabled) return false;
if (shader_backend_->getActiveShader() == Rendering::ShaderType::POSTFX) { if (shader_backend_->getActiveShader() == Rendering::ShaderType::POSTFX) {
if (Options::postfx_presets.empty()) return; if (Options::postfx_presets.empty()) return false;
int n = static_cast<int>(Options::postfx_presets.size()); int n = static_cast<int>(Options::postfx_presets.size());
Options::current_postfx_preset = (Options::current_postfx_preset - 1 + n) % n; Options::current_postfx_preset = (Options::current_postfx_preset - 1 + n) % n;
Options::video.current_postfx_preset = Options::postfx_presets[Options::current_postfx_preset].name; Options::video.current_postfx_preset = Options::postfx_presets[Options::current_postfx_preset].name;
applyCurrentPostFXPreset(); applyCurrentPostFXPreset();
} else { } else {
if (Options::crtpi_presets.empty()) return; if (Options::crtpi_presets.empty()) return false;
int n = static_cast<int>(Options::crtpi_presets.size()); int n = static_cast<int>(Options::crtpi_presets.size());
Options::current_crtpi_preset = (Options::current_crtpi_preset - 1 + n) % n; Options::current_crtpi_preset = (Options::current_crtpi_preset - 1 + n) % n;
Options::video.current_crtpi_preset = Options::crtpi_presets[Options::current_crtpi_preset].name; Options::video.current_crtpi_preset = Options::crtpi_presets[Options::current_crtpi_preset].name;
applyCurrentCrtPiPreset(); applyCurrentCrtPiPreset();
} }
return true;
} }
auto Screen::getCurrentPresetName() const -> const char* { auto Screen::getCurrentPresetName() const -> const char* {
@@ -371,6 +538,77 @@ void Screen::updateRenderInfo() {
0b1001); 0b1001);
} }
void Screen::applyFallbackPresentation() {
// Fallback SDL_Renderer (p.ex. emscripten/WebGL2 sense shaders GPU).
// Filtre global (texture_filter) s'aplica sempre, independent de 4:3.
SDL_ScaleMode scale = (Options::video.texture_filter == Options::TextureFilter::LINEAR)
? SDL_SCALEMODE_LINEAR
: SDL_SCALEMODE_NEAREST;
if (texture_) SDL_SetTextureScaleMode(texture_, scale);
// Si 4:3 actiu, la finestra ja té aspect 4:3 (alçada × 1.2); STRETCH és
// l'única opció viable al path fallback (el GPU path fa l'upscale 4:3 abans
// d'escollir el mode de finestra; en fallback no tenim eixa capa intermèdia).
SDL_RendererLogicalPresentation mode = SDL_LOGICAL_PRESENTATION_LETTERBOX;
if (Options::video.aspect_ratio_4_3) {
mode = SDL_LOGICAL_PRESENTATION_STRETCH;
} else {
switch (Options::video.scaling_mode) {
case Options::ScalingMode::DISABLED:
mode = SDL_LOGICAL_PRESENTATION_DISABLED;
break;
case Options::ScalingMode::STRETCH:
mode = SDL_LOGICAL_PRESENTATION_STRETCH;
break;
case Options::ScalingMode::LETTERBOX:
mode = SDL_LOGICAL_PRESENTATION_LETTERBOX;
break;
case Options::ScalingMode::OVERSCAN:
mode = SDL_LOGICAL_PRESENTATION_OVERSCAN;
break;
case Options::ScalingMode::INTEGER:
mode = SDL_LOGICAL_PRESENTATION_INTEGER_SCALE;
break;
}
}
// Amb resolució interna N > 1, la mida lògica creix proporcionalment
// perquè SDL scale des de 320·N × 200·N a la finestra — menys aggressive linear.
const int mult = Options::video.internal_resolution < 1 ? 1 : Options::video.internal_resolution;
SDL_SetRenderLogicalPresentation(renderer_, GAME_WIDTH * mult, GAME_HEIGHT * mult, mode);
}
void Screen::ensureFallbackInternalTexture() {
if (renderer_ == nullptr) return;
const int mult = Options::video.internal_resolution;
if (mult <= 1) {
// No cal textura intermèdia — recicla si la teníem.
if (internal_texture_sdl_ != nullptr) {
SDL_DestroyTexture(internal_texture_sdl_);
internal_texture_sdl_ = nullptr;
internal_texture_mult_ = 0;
}
return;
}
if (internal_texture_sdl_ != nullptr && internal_texture_mult_ == mult) return;
if (internal_texture_sdl_ != nullptr) {
SDL_DestroyTexture(internal_texture_sdl_);
internal_texture_sdl_ = nullptr;
}
internal_texture_sdl_ = SDL_CreateTexture(renderer_,
SDL_PIXELFORMAT_ABGR8888,
SDL_TEXTUREACCESS_TARGET,
GAME_WIDTH * mult,
GAME_HEIGHT * mult);
if (internal_texture_sdl_ == nullptr) {
std::cerr << "Screen: failed to create fallback internal texture (×" << mult << "): "
<< SDL_GetError() << '\n';
internal_texture_mult_ = 0;
return;
}
internal_texture_mult_ = mult;
}
void Screen::adjustWindowSize() { void Screen::adjustWindowSize() {
int w = GAME_WIDTH * zoom_; int w = GAME_WIDTH * zoom_;
// Si 4:3 actiu, l'alçada visual és 240 per zoom (200 * 1.2) // Si 4:3 actiu, l'alçada visual és 240 per zoom (200 * 1.2)
@@ -389,3 +627,25 @@ void Screen::calculateMaxZoom() {
if (max_zoom_ < 1) max_zoom_ = 1; if (max_zoom_ < 1) max_zoom_ = 1;
} }
} }
#ifdef __EMSCRIPTEN__
// ============================================================================
// Emscripten — fix per a fullscreen/resize (veure el bloc de comentaris al
// principi del fitxer i l'anonymous namespace amb els callbacks natius).
// ============================================================================
void Screen::handleCanvasResized() {
if (window_ == nullptr) return;
// Re-sincronitza l'estat intern de SDL amb el canvas HTML real. La crida
// a SDL_SetWindowFullscreen és l'única manera de forçar SDL a reconèixer
// la mida actual del canvas; després re-apliquem la logical presentation
// (el path WASM sempre va pel fallback SDL_Renderer, sense shaders GPU).
SDL_SetWindowFullscreen(window_, fullscreen_);
applyFallbackPresentation();
}
void Screen::syncFullscreenFlagFromBrowser(bool is_fullscreen) {
fullscreen_ = is_fullscreen;
Options::window.fullscreen = is_fullscreen;
}
#endif

View File

@@ -13,6 +13,8 @@ class Screen {
static void destroy(); static void destroy();
static auto get() -> Screen*; static auto get() -> Screen*;
~Screen(); // públic per a std::unique_ptr
// Presentació — rep el buffer ARGB de 320x200 de JD8 // Presentació — rep el buffer ARGB de 320x200 de JD8
void present(Uint32* pixel_data); void present(Uint32* pixel_data);
@@ -23,16 +25,21 @@ class Screen {
void setZoom(int zoom); void setZoom(int zoom);
// Shaders i vídeo // Shaders i vídeo
// Mètodes que depenen d'una precondició (GPU present, shaders on, etc.)
// retornen `bool`: true si l'acció s'ha aplicat, false si la precondició
// no es complia. Els callers (F-keys, menú) poden suprimir notificacions
// o feedback quan la crida no ha tingut efecte.
void toggleShaders(); void toggleShaders();
void toggleSupersampling(); auto toggleSupersampling() -> bool; // false si GPU off / shaders off / actiu != POSTFX
void toggleAspectRatio(); void toggleAspectRatio();
void toggleIntegerScale(); void cycleScalingMode(int dir); // Cicla DISABLED/STRETCH/LETTERBOX/OVERSCAN/INTEGER
void toggleVSync(); void toggleVSync();
void toggleStretchFilter(); void cycleTextureFilter(int dir); // Cicla NEAREST/LINEAR
void nextShaderType(); // Cicla PostFX ↔ CrtPi (F7) void changeInternalResolution(int dir); // +/1, clampat a [1, max_zoom_]
void prevShaderType(); // Cicla al revés auto nextShaderType() -> bool; // false si GPU off / shaders off
void nextPreset(); // Cicla presets del shader actiu (F8) auto prevShaderType() -> bool; // idem
void prevPreset(); // Cicla presets al revés auto nextPreset() -> bool; // false si GPU off / shaders off
auto prevPreset() -> bool; // idem
[[nodiscard]] auto getCurrentPresetName() const -> const char*; [[nodiscard]] auto getCurrentPresetName() const -> const char*;
void setActiveShader(Rendering::ShaderType type); void setActiveShader(Rendering::ShaderType type);
void applyCurrentPostFXPreset(); void applyCurrentPostFXPreset();
@@ -41,24 +48,36 @@ class Screen {
// Getters // Getters
[[nodiscard]] auto isFullscreen() const -> bool { return fullscreen_; } [[nodiscard]] auto isFullscreen() const -> bool { return fullscreen_; }
[[nodiscard]] auto getZoom() const -> int { return zoom_; } [[nodiscard]] auto getZoom() const -> int { return zoom_; }
[[nodiscard]] auto getMaxZoom() const -> int { return max_zoom_; }
[[nodiscard]] auto isHardwareAccelerated() const -> bool; [[nodiscard]] auto isHardwareAccelerated() const -> bool;
[[nodiscard]] auto getActiveShaderName() const -> const char*; [[nodiscard]] auto getActiveShaderName() const -> const char*;
[[nodiscard]] auto getWindow() -> SDL_Window* { return window_; } [[nodiscard]] auto getWindow() -> SDL_Window* { return window_; }
[[nodiscard]] auto getRenderer() -> SDL_Renderer* { return renderer_; } [[nodiscard]] auto getRenderer() -> SDL_Renderer* { return renderer_; }
#ifdef __EMSCRIPTEN__
// Sincronització amb el canvas HTML quan el navegador canvia la mida
// (fullscreen entrant/eixint, rotació de mòbil). Cridat pels callbacks
// natius d'Emscripten registrats al constructor.
void handleCanvasResized();
void syncFullscreenFlagFromBrowser(bool is_fullscreen);
#endif
private: private:
Screen(); Screen();
~Screen();
void adjustWindowSize(); void adjustWindowSize();
void calculateMaxZoom(); void calculateMaxZoom();
void initShaders(); void initShaders();
void applyFallbackPresentation(); // Logical presentation + scale mode per al path SDL_Renderer
void ensureFallbackInternalTexture(); // Recrea internal_texture_sdl_ si cal (fallback path)
static Screen* instance_; static std::unique_ptr<Screen> instance_;
SDL_Window* window_{nullptr}; SDL_Window* window_{nullptr};
SDL_Renderer* renderer_{nullptr}; SDL_Renderer* renderer_{nullptr};
SDL_Texture* texture_{nullptr}; // 320x200 streaming, ABGR8888 (fallback SDL_Renderer) SDL_Texture* texture_{nullptr}; // 320x200 streaming, ABGR8888 (fallback SDL_Renderer)
SDL_Texture* internal_texture_sdl_{nullptr}; // 320·N x 200·N TARGET (fallback path, només si N>1)
int internal_texture_mult_{0}; // Multiplicador amb què es va crear internal_texture_sdl_
// Backend GPU (nullptr si no disponible o desactivat) // Backend GPU (nullptr si no disponible o desactivat)
std::unique_ptr<Rendering::ShaderBackend> shader_backend_; std::unique_ptr<Rendering::ShaderBackend> shader_backend_;

View File

@@ -8,11 +8,11 @@
#include <iostream> // std::cout #include <iostream> // std::cout
#ifndef __APPLE__ #ifndef __APPLE__
#include "core/rendering/sdl3gpu/crtpi_frag_spv.h" #include "core/rendering/sdl3gpu/spv/crtpi_frag_spv.h"
#include "core/rendering/sdl3gpu/downscale_frag_spv.h" #include "core/rendering/sdl3gpu/spv/downscale_frag_spv.h"
#include "core/rendering/sdl3gpu/postfx_frag_spv.h" #include "core/rendering/sdl3gpu/spv/postfx_frag_spv.h"
#include "core/rendering/sdl3gpu/postfx_vert_spv.h" #include "core/rendering/sdl3gpu/spv/postfx_vert_spv.h"
#include "core/rendering/sdl3gpu/upscale_frag_spv.h" #include "core/rendering/sdl3gpu/spv/upscale_frag_spv.h"
#endif #endif
#ifdef __APPLE__ #ifdef __APPLE__
@@ -456,6 +456,11 @@ namespace Rendering {
return false; return false;
} }
// internal_texture_: si el multiplicador és > 1, es crea ací amb les
// dimensions game·N × game·N. No bloqueja si falla — només deixa la
// textura a nullptr i el pipeline ometrà la còpia.
recreateInternalTexture();
// scaled_texture_ se creará en el primer render() una vez conocido el zoom de ventana // scaled_texture_ se creará en el primer render() una vez conocido el zoom de ventana
ss_factor_ = 0; ss_factor_ = 0;
@@ -812,14 +817,50 @@ namespace Rendering {
SDL_EndGPUCopyPass(copy); SDL_EndGPUCopyPass(copy);
} }
// ---- Upscale pass: scene_texture_ → scaled_texture_ ---- // ---- Internal resolution NN upscale: scene_texture_ → internal_texture_ ----
// Multiplicador enter. Si > 1, tot el pipeline downstream veu internal_texture_
// com a "scene" (mida game·N × game·N) i els passos següents (SS, PostFX,
// Lanczos, letterbox) operen sobre aquesta font més gran. L'objectiu: quan el
// filtre final LINEAR estira a finestra, parteix d'una base més gran i es veu
// menys borrós. Amb internal_res_ == 1, s'omet el pas (zero overhead).
SDL_GPUTexture* source_texture = scene_texture_;
int source_width = game_width_;
int source_height = game_height_;
if (internal_res_ > 1 && internal_texture_ != nullptr && upscale_pipeline_ != nullptr) {
SDL_GPUColorTargetInfo internal_target = {};
internal_target.texture = internal_texture_;
internal_target.load_op = SDL_GPU_LOADOP_DONT_CARE;
internal_target.store_op = SDL_GPU_STOREOP_STORE;
SDL_GPURenderPass* ipass = SDL_BeginGPURenderPass(cmd, &internal_target, 1, nullptr);
if (ipass != nullptr) {
SDL_BindGPUGraphicsPipeline(ipass, upscale_pipeline_);
SDL_GPUTextureSamplerBinding ibinding = {};
ibinding.texture = scene_texture_;
ibinding.sampler = sampler_; // sempre NEAREST per a la còpia de resolució interna
SDL_BindGPUFragmentSamplers(ipass, 0, &ibinding, 1);
SDL_DrawGPUPrimitives(ipass, 3, 1, 0, 0);
SDL_EndGPURenderPass(ipass);
}
source_texture = internal_texture_;
source_width = game_width_ * internal_res_;
source_height = game_height_ * internal_res_;
}
// ---- Upscale pass: source_texture → scaled_texture_ ----
// Si 4:3 actiu, l'estirament s'aplica ací directament (320x200 → W*factor × H*factor*1.2) // Si 4:3 actiu, l'estirament s'aplica ací directament (320x200 → W*factor × H*factor*1.2)
// El filtre per al 4:3 és configurable (stretch_filter_linear_). // El filtre s'aplica sempre (texture_filter_linear_), independent de 4:3.
// L'effective_scene/height reflecteix la textura real que veuen els shaders. // L'effective_scene/height reflecteix la textura real que veuen els shaders.
// Sense SS ni stretch: scene_texture_ a game_height_. // Sense SS ni stretch: scene_texture_ a game_height_.
// Amb SS o stretch: scaled_texture_ a l'alçada escalada (amb o sense 4:3). // Amb SS o stretch: scaled_texture_ a l'alçada escalada (amb o sense 4:3).
SDL_GPUTexture* effective_scene = scene_texture_; SDL_GPUTexture* effective_scene = source_texture;
// `effective_height` reflecteix l'alçada lògica del frame (per a
// scanlines i viewport), no la mida real de la textura. Es manté
// a `game_height_` encara que internal_res_ > 1 — el multiplicador
// només afecta la resolució física de la font, no l'aspect ni el
// nombre de scanlines visibles.
int effective_height = game_height_; int effective_height = game_height_;
(void)source_width; // només es fa servir com a context informatiu
if (oversample_ > 1 && scaled_texture_ != nullptr && upscale_pipeline_ != nullptr) { if (oversample_ > 1 && scaled_texture_ != nullptr && upscale_pipeline_ != nullptr) {
SDL_GPUColorTargetInfo upscale_target = {}; SDL_GPUColorTargetInfo upscale_target = {};
@@ -827,15 +868,14 @@ namespace Rendering {
upscale_target.load_op = SDL_GPU_LOADOP_DONT_CARE; upscale_target.load_op = SDL_GPU_LOADOP_DONT_CARE;
upscale_target.store_op = SDL_GPU_STOREOP_STORE; upscale_target.store_op = SDL_GPU_STOREOP_STORE;
// Triar filtre: si 4:3 actiu, usar el filtre configurable per a l'estirament. // Filtre global: s'aplica sempre (ja no depèn de 4:3).
// Si no, usar el filtre d'upscale normal (linear_upscale_). bool use_linear = texture_filter_linear_;
bool use_linear = stretch_4_3_ ? stretch_filter_linear_ : linear_upscale_;
SDL_GPURenderPass* upass = SDL_BeginGPURenderPass(cmd, &upscale_target, 1, nullptr); SDL_GPURenderPass* upass = SDL_BeginGPURenderPass(cmd, &upscale_target, 1, nullptr);
if (upass != nullptr) { if (upass != nullptr) {
SDL_BindGPUGraphicsPipeline(upass, upscale_pipeline_); SDL_BindGPUGraphicsPipeline(upass, upscale_pipeline_);
SDL_GPUTextureSamplerBinding ubinding = {}; SDL_GPUTextureSamplerBinding ubinding = {};
ubinding.texture = scene_texture_; ubinding.texture = source_texture;
ubinding.sampler = (use_linear && linear_sampler_ != nullptr) ? linear_sampler_ : sampler_; ubinding.sampler = (use_linear && linear_sampler_ != nullptr) ? linear_sampler_ : sampler_;
SDL_BindGPUFragmentSamplers(upass, 0, &ubinding, 1); SDL_BindGPUFragmentSamplers(upass, 0, &ubinding, 1);
SDL_DrawGPUPrimitives(upass, 3, 1, 0, 0); SDL_DrawGPUPrimitives(upass, 3, 1, 0, 0);
@@ -847,6 +887,7 @@ namespace Rendering {
// Sense SS: el viewport s'encarrega de l'estirament geomètric // Sense SS: el viewport s'encarrega de l'estirament geomètric
effective_height = static_cast<int>(static_cast<float>(game_height_) * 1.2F); effective_height = static_cast<int>(static_cast<float>(game_height_) * 1.2F);
} }
(void)source_height;
// ---- Acquire swapchain texture ---- // ---- Acquire swapchain texture ----
SDL_GPUTexture* swapchain = nullptr; SDL_GPUTexture* swapchain = nullptr;
@@ -872,16 +913,37 @@ namespace Rendering {
float vy = 0.0F; float vy = 0.0F;
float vw = 0.0F; float vw = 0.0F;
float vh = 0.0F; float vh = 0.0F;
if (integer_scale_) { switch (scaling_mode_) {
const int SCALE = std::max(1, std::min(static_cast<int>(sw) / static_cast<int>(logical_w), static_cast<int>(sh) / static_cast<int>(logical_h))); case Options::ScalingMode::DISABLED:
vw = logical_w * static_cast<float>(SCALE); // 1:1, sense escala (pot ser diminut en finestres grans)
vh = logical_h * static_cast<float>(SCALE); vw = logical_w;
} else { vh = logical_h;
const float SCALE = std::min( break;
static_cast<float>(sw) / logical_w, case Options::ScalingMode::STRETCH:
// Omple tota la finestra, escala no uniforme
vw = static_cast<float>(sw);
vh = static_cast<float>(sh);
break;
case Options::ScalingMode::LETTERBOX: {
const float SCALE = std::min(static_cast<float>(sw) / logical_w,
static_cast<float>(sh) / logical_h); static_cast<float>(sh) / logical_h);
vw = logical_w * SCALE; vw = logical_w * SCALE;
vh = logical_h * SCALE; vh = logical_h * SCALE;
break;
}
case Options::ScalingMode::OVERSCAN: {
const float SCALE = std::max(static_cast<float>(sw) / logical_w,
static_cast<float>(sh) / logical_h);
vw = logical_w * SCALE;
vh = logical_h * SCALE;
break;
}
case Options::ScalingMode::INTEGER: {
const int SCALE = std::max(1, std::min(static_cast<int>(sw) / static_cast<int>(logical_w), static_cast<int>(sh) / static_cast<int>(logical_h)));
vw = logical_w * static_cast<float>(SCALE);
vh = logical_h * static_cast<float>(SCALE);
break;
}
} }
vx = std::floor((static_cast<float>(sw) - vw) * 0.5F); vx = std::floor((static_cast<float>(sw) - vw) * 0.5F);
vy = std::floor((static_cast<float>(sh) - vh) * 0.5F); vy = std::floor((static_cast<float>(sh) - vh) * 0.5F);
@@ -914,9 +976,14 @@ namespace Rendering {
SDL_GPUViewport vp = {.x = vx, .y = vy, .w = vw, .h = vh, .min_depth = 0.0F, .max_depth = 1.0F}; SDL_GPUViewport vp = {.x = vx, .y = vy, .w = vw, .h = vh, .min_depth = 0.0F, .max_depth = 1.0F};
SDL_SetGPUViewport(pass, &vp); SDL_SetGPUViewport(pass, &vp);
// El shader CrtPi tradicionalment usa NEAREST per a fer el seu
// propi filtrat analític. Si l'usuari tria LINEAR explícitament,
// respectem la preferència (la mostra arribarà pre-suavitzada).
SDL_GPUTextureSamplerBinding binding = {}; SDL_GPUTextureSamplerBinding binding = {};
binding.texture = effective_scene; binding.texture = effective_scene;
binding.sampler = sampler_; // NEAREST: el shader CrtPi fa el seu propi filtrat analític binding.sampler = (texture_filter_linear_ && linear_sampler_ != nullptr)
? linear_sampler_
: sampler_;
SDL_BindGPUFragmentSamplers(pass, 0, &binding, 1); SDL_BindGPUFragmentSamplers(pass, 0, &binding, 1);
// Injectar texture_width/height abans del push // Injectar texture_width/height abans del push
@@ -991,11 +1058,15 @@ namespace Rendering {
SDL_GPUViewport vp = {.x = vx, .y = vy, .w = vw, .h = vh, .min_depth = 0.0F, .max_depth = 1.0F}; SDL_GPUViewport vp = {.x = vx, .y = vy, .w = vw, .h = vh, .min_depth = 0.0F, .max_depth = 1.0F};
SDL_SetGPUViewport(pass, &vp); SDL_SetGPUViewport(pass, &vp);
// Amb SS: llegir de scaled_texture_ amb LINEAR; sense SS: effective_scene amb NEAREST. // Font: amb SS scaled_texture_; sense SS, effective_scene (que ja
// és internal_texture_ si internal_res_>1, o scene_texture_ si no).
// Sampler: honora el filtre global que l'usuari tria al menú
// (texture_filter_linear_). Abans estava hardcoded a NEAREST
// quan SS era off — el menú no tenia efecte visible en aquest path.
SDL_GPUTexture* input_texture = (oversample_ > 1 && scaled_texture_ != nullptr) SDL_GPUTexture* input_texture = (oversample_ > 1 && scaled_texture_ != nullptr)
? scaled_texture_ ? scaled_texture_
: effective_scene; : effective_scene;
SDL_GPUSampler* active_sampler = (oversample_ > 1 && linear_sampler_ != nullptr) SDL_GPUSampler* active_sampler = (texture_filter_linear_ && linear_sampler_ != nullptr)
? linear_sampler_ ? linear_sampler_
: sampler_; : sampler_;
@@ -1047,6 +1118,10 @@ namespace Rendering {
SDL_ReleaseGPUTexture(device_, scene_texture_); SDL_ReleaseGPUTexture(device_, scene_texture_);
scene_texture_ = nullptr; scene_texture_ = nullptr;
} }
if (internal_texture_ != nullptr) {
SDL_ReleaseGPUTexture(device_, internal_texture_);
internal_texture_ = nullptr;
}
if (scaled_texture_ != nullptr) { if (scaled_texture_ != nullptr) {
SDL_ReleaseGPUTexture(device_, scaled_texture_); SDL_ReleaseGPUTexture(device_, scaled_texture_);
scaled_texture_ = nullptr; scaled_texture_ = nullptr;
@@ -1193,8 +1268,20 @@ namespace Rendering {
} }
} }
void SDL3GPUShader::setScaleMode(bool integer_scale) { void SDL3GPUShader::setScalingMode(Options::ScalingMode mode) {
integer_scale_ = integer_scale; scaling_mode_ = mode;
}
// setInternalResolution — canvia el multiplicador de resolució interna.
// Recrea la textura intermèdia amb les noves dimensions (320·N × 200·N).
void SDL3GPUShader::setInternalResolution(int multiplier) {
const int NEW = std::max(1, multiplier);
if (NEW == internal_res_) return;
internal_res_ = NEW;
if (is_initialized_ && device_ != nullptr) {
SDL_WaitForGPUIdle(device_);
recreateInternalTexture();
}
} }
void SDL3GPUShader::setStretch4_3(bool enabled) { void SDL3GPUShader::setStretch4_3(bool enabled) {
@@ -1221,10 +1308,6 @@ namespace Rendering {
} }
} }
void SDL3GPUShader::setLinearUpscale(bool linear) {
linear_upscale_ = linear;
}
void SDL3GPUShader::setDownscaleAlgo(int algo) { void SDL3GPUShader::setDownscaleAlgo(int algo) {
downscale_algo_ = std::max(0, std::min(algo, 2)); downscale_algo_ = std::max(0, std::min(algo, 2));
} }
@@ -1246,6 +1329,10 @@ namespace Rendering {
SDL_ReleaseGPUTexture(device_, scene_texture_); SDL_ReleaseGPUTexture(device_, scene_texture_);
scene_texture_ = nullptr; scene_texture_ = nullptr;
} }
if (internal_texture_ != nullptr) {
SDL_ReleaseGPUTexture(device_, internal_texture_);
internal_texture_ = nullptr;
}
// scaled_texture_ se libera aquí; se recreará en el primer render() con el factor correcto // scaled_texture_ se libera aquí; se recreará en el primer render() con el factor correcto
if (scaled_texture_ != nullptr) { if (scaled_texture_ != nullptr) {
SDL_ReleaseGPUTexture(device_, scaled_texture_); SDL_ReleaseGPUTexture(device_, scaled_texture_);
@@ -1288,10 +1375,15 @@ namespace Rendering {
return false; return false;
} }
SDL_Log("SDL3GPUShader: reinit — scene %dx%d, SS %s (scaled se creará en render)", // Recrea la textura interna si internal_res_ > 1 — manté coherència
// en canvis d'SS que passen per reinitTexturesAndBuffer().
recreateInternalTexture();
SDL_Log("SDL3GPUShader: reinit — scene %dx%d, SS %s, internal ×%d (scaled se creará en render)",
game_width_, game_width_,
game_height_, game_height_,
oversample_ > 1 ? "on" : "off"); oversample_ > 1 ? "on" : "off",
internal_res_);
return true; return true;
} }
@@ -1362,4 +1454,42 @@ namespace Rendering {
return true; return true;
} }
// ---------------------------------------------------------------------------
// recreateInternalTexture — libera y recrea internal_texture_ para el
// multiplicador internal_res_ actual. Si val 1, allibera i queda a nullptr
// (el pipeline ometrà la còpia al següent render).
// ---------------------------------------------------------------------------
auto SDL3GPUShader::recreateInternalTexture() -> bool {
if (internal_texture_ != nullptr) {
SDL_ReleaseGPUTexture(device_, internal_texture_);
internal_texture_ = nullptr;
}
if (internal_res_ <= 1 || device_ == nullptr) return true;
const int W = game_width_ * internal_res_;
const int H = game_height_ * internal_res_;
SDL_GPUTextureCreateInfo info = {};
info.type = SDL_GPU_TEXTURETYPE_2D;
info.format = SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM;
info.usage = SDL_GPU_TEXTUREUSAGE_SAMPLER | SDL_GPU_TEXTUREUSAGE_COLOR_TARGET;
info.width = static_cast<Uint32>(W);
info.height = static_cast<Uint32>(H);
info.layer_count_or_depth = 1;
info.num_levels = 1;
internal_texture_ = SDL_CreateGPUTexture(device_, &info);
if (internal_texture_ == nullptr) {
SDL_Log("SDL3GPUShader: failed to create internal texture %dx%d (×%d): %s",
W,
H,
internal_res_,
SDL_GetError());
return false;
}
SDL_Log("SDL3GPUShader: internal texture %dx%d (×%d)", W, H, internal_res_);
return true;
}
} // namespace Rendering } // namespace Rendering

View File

@@ -96,15 +96,12 @@ namespace Rendering {
// Activa/desactiva VSync en el swapchain // Activa/desactiva VSync en el swapchain
void setVSync(bool vsync) override; void setVSync(bool vsync) override;
// Activa/desactiva escalado entero (integer scale) // Selecciona el mode de presentació lògica (DISABLED/STRETCH/LETTERBOX/OVERSCAN/INTEGER)
void setScaleMode(bool integer_scale) override; void setScalingMode(Options::ScalingMode mode) override;
// Establece factor de supersampling (1 = off, 3 = 3×SS) // Establece factor de supersampling (1 = off, 3 = 3×SS)
void setOversample(int factor) override; void setOversample(int factor) override;
// Activa/desactiva interpolación LINEAR en el upscale (false = NEAREST)
void setLinearUpscale(bool linear) override;
// Selecciona algoritmo de downscale: 0=bilinear legacy, 1=Lanczos2, 2=Lanczos3 // Selecciona algoritmo de downscale: 0=bilinear legacy, 1=Lanczos2, 2=Lanczos3
void setDownscaleAlgo(int algo) override; void setDownscaleAlgo(int algo) override;
@@ -123,7 +120,14 @@ namespace Rendering {
// Estirament vertical 4:3 (fusionat amb l'upscale pass) // Estirament vertical 4:3 (fusionat amb l'upscale pass)
void setStretch4_3(bool enabled) override; void setStretch4_3(bool enabled) override;
[[nodiscard]] auto isStretch4_3() const -> bool override { return stretch_4_3_; } [[nodiscard]] auto isStretch4_3() const -> bool override { return stretch_4_3_; }
void setStretchFilter(bool linear) override { stretch_filter_linear_ = linear; }
// Filtre de textura global (sempre aplicat, independent de 4:3)
void setTextureFilter(Options::TextureFilter filter) override {
texture_filter_linear_ = (filter == Options::TextureFilter::LINEAR);
}
// Multiplicador de resolució interna (1 = off).
void setInternalResolution(int multiplier) override;
private: private:
static auto createShaderMSL(SDL_GPUDevice* device, static auto createShaderMSL(SDL_GPUDevice* device,
@@ -145,6 +149,7 @@ namespace Rendering {
auto createCrtPiPipeline() -> bool; // Pipeline dedicado para el shader CrtPi auto createCrtPiPipeline() -> bool; // Pipeline dedicado para el shader CrtPi
auto reinitTexturesAndBuffer() -> bool; // Recrea scene_texture_ y upload_buffer_ auto reinitTexturesAndBuffer() -> bool; // Recrea scene_texture_ y upload_buffer_
auto recreateScaledTexture(int factor) -> bool; // Recrea scaled_texture_ para factor dado auto recreateScaledTexture(int factor) -> bool; // Recrea scaled_texture_ para factor dado
auto recreateInternalTexture() -> bool; // Recrea internal_texture_ (res interna × N)
static auto calcSsFactor(float zoom) -> int; // Primer múltiplo de 3 >= zoom (mín 3) static auto calcSsFactor(float zoom) -> int; // Primer múltiplo de 3 >= zoom (mín 3)
// Devuelve el mejor present mode disponible: IMMEDIATE > MAILBOX > VSYNC // Devuelve el mejor present mode disponible: IMMEDIATE > MAILBOX > VSYNC
[[nodiscard]] auto bestPresentMode(bool vsync) const -> SDL_GPUPresentMode; [[nodiscard]] auto bestPresentMode(bool vsync) const -> SDL_GPUPresentMode;
@@ -157,6 +162,7 @@ namespace Rendering {
SDL_GPUGraphicsPipeline* upscale_pipeline_ = nullptr; // Upscale pass (solo con SS) SDL_GPUGraphicsPipeline* upscale_pipeline_ = nullptr; // Upscale pass (solo con SS)
SDL_GPUGraphicsPipeline* downscale_pipeline_ = nullptr; // Lanczos downscale (solo con SS + algo > 0) SDL_GPUGraphicsPipeline* downscale_pipeline_ = nullptr; // Lanczos downscale (solo con SS + algo > 0)
SDL_GPUTexture* scene_texture_ = nullptr; // Canvas del joc (game_width_ × game_height_) SDL_GPUTexture* scene_texture_ = nullptr; // Canvas del joc (game_width_ × game_height_)
SDL_GPUTexture* internal_texture_ = nullptr; // Resolució interna ampliada (game·N × game·N), si N>1
SDL_GPUTexture* scaled_texture_ = nullptr; // Upscale target (game×factor, amb 4:3 si actiu) SDL_GPUTexture* scaled_texture_ = nullptr; // Upscale target (game×factor, amb 4:3 si actiu)
SDL_GPUTexture* postfx_texture_ = nullptr; // PostFX output a resolució escalada, sols amb Lanczos SDL_GPUTexture* postfx_texture_ = nullptr; // PostFX output a resolució escalada, sols amb Lanczos
SDL_GPUTransferBuffer* upload_buffer_ = nullptr; SDL_GPUTransferBuffer* upload_buffer_ = nullptr;
@@ -172,14 +178,14 @@ namespace Rendering {
int ss_factor_ = 0; // Factor SS activo (3, 6, 9...) o 0 si SS desactivado int ss_factor_ = 0; // Factor SS activo (3, 6, 9...) o 0 si SS desactivado
int oversample_ = 1; // SS on/off (1 = off, >1 = on) int oversample_ = 1; // SS on/off (1 = off, >1 = on)
int downscale_algo_ = 1; // 0 = bilinear legacy, 1 = Lanczos2, 2 = Lanczos3 int downscale_algo_ = 1; // 0 = bilinear legacy, 1 = Lanczos2, 2 = Lanczos3
int internal_res_ = 1; // Multiplicador de resolució interna (1 = off)
std::string driver_name_; std::string driver_name_;
std::string preferred_driver_; // Driver preferido; vacío = auto (SDL elige) std::string preferred_driver_; // Driver preferido; vacío = auto (SDL elige)
bool is_initialized_ = false; bool is_initialized_ = false;
bool vsync_ = true; bool vsync_ = true;
bool integer_scale_ = false; Options::ScalingMode scaling_mode_ = Options::ScalingMode::INTEGER;
bool linear_upscale_ = false; // Upscale NEAREST (false) o LINEAR (true)
bool stretch_4_3_ = false; // Estirament vertical 4:3 bool stretch_4_3_ = false; // Estirament vertical 4:3
bool stretch_filter_linear_ = false; // Filtre per a l'estirament 4:3 (false=NEAREST, true=LINEAR) bool texture_filter_linear_ = false; // Filtre global (false=NEAREST, true=LINEAR)
}; };
} // namespace Rendering } // namespace Rendering

View File

@@ -0,0 +1,2 @@
DisableFormat: true
SortIncludes: Never

View File

@@ -0,0 +1,4 @@
# source/core/rendering/sdl3gpu/spv/.clang-tidy
Checks: '-*'
WarningsAsErrors: ''
HeaderFilterRegex: ''

View File

@@ -5,6 +5,8 @@
#include <string> #include <string>
#include <utility> #include <utility>
#include "game/options.hpp"
namespace Rendering { namespace Rendering {
/** @brief Identificador del shader de post-procesado activo */ /** @brief Identificador del shader de post-procesado activo */
@@ -105,9 +107,9 @@ namespace Rendering {
virtual void setVSync(bool /*vsync*/) {} virtual void setVSync(bool /*vsync*/) {}
/** /**
* @brief Activa o desactiva el escalado entero (integer scale) * @brief Selecciona el mode d'escala de la finestra (mapeja SDL_RendererLogicalPresentation).
*/ */
virtual void setScaleMode(bool /*integer_scale*/) {} virtual void setScalingMode(Options::ScalingMode /*mode*/) {}
/** /**
* @brief Establece el factor de supersampling (1 = off, 3 = 3× SS) * @brief Establece el factor de supersampling (1 = off, 3 = 3× SS)
@@ -116,13 +118,6 @@ namespace Rendering {
*/ */
virtual void setOversample(int /*factor*/) {} virtual void setOversample(int /*factor*/) {}
/**
* @brief Activa/desactiva interpolación LINEAR en el paso de upscale (SS).
* Por defecto NEAREST (false). Solo tiene efecto con supersampling activo.
*/
virtual void setLinearUpscale(bool /*linear*/) {}
[[nodiscard]] virtual auto isLinearUpscale() const -> bool { return false; }
/** /**
* @brief Selecciona el algoritmo de downscale tras el PostFX (SS activo). * @brief Selecciona el algoritmo de downscale tras el PostFX (SS activo).
* 0 = bilinear legacy (comportamiento actual, sin textura intermedia), * 0 = bilinear legacy (comportamiento actual, sin textura intermedia),
@@ -179,9 +174,16 @@ namespace Rendering {
[[nodiscard]] virtual auto isStretch4_3() const -> bool { return false; } [[nodiscard]] virtual auto isStretch4_3() const -> bool { return false; }
/** /**
* @brief Filtre per a l'estirament 4:3 (false=NEAREST, true=LINEAR). * @brief Filtre de textura global per a l'upscale final (sempre aplicat).
*/ */
virtual void setStretchFilter(bool /*linear*/) {} virtual void setTextureFilter(Options::TextureFilter /*filter*/) {}
/**
* @brief Multiplicador enter de la "resolució interna": fa un NN upscale
* de scene (320×200) a 320·N × 200·N i la pipeline downstream
* parteix d'aquesta textura. 1 = off (sense còpia addicional).
*/
virtual void setInternalResolution(int /*multiplier*/) {}
}; };
} // namespace Rendering } // namespace Rendering

View File

@@ -8,7 +8,7 @@
#include <sstream> #include <sstream>
#include <string> #include <string>
#include "core/jail/jfile.hpp" #include "core/resources/resource_helper.hpp"
// Forward declarations de gif.h (inclòs des de jdraw8.cpp, no es pot incloure dos vegades) // Forward declarations de gif.h (inclòs des de jdraw8.cpp, no es pot incloure dos vegades)
struct rgb; struct rgb;
@@ -19,10 +19,6 @@ Text::Text(const char* fnt_file, const char* gif_file) {
loadFont(fnt_file); loadFont(fnt_file);
} }
Text::~Text() {
if (bitmap_) free(bitmap_);
}
// --- UTF-8 --- // --- UTF-8 ---
auto Text::nextCodepoint(const char*& ptr) -> uint32_t { auto Text::nextCodepoint(const char*& ptr) -> uint32_t {
@@ -62,15 +58,13 @@ auto Text::nextCodepoint(const char*& ptr) -> uint32_t {
// --- Càrrega de font --- // --- Càrrega de font ---
void Text::loadFont(const char* fnt_file) { void Text::loadFont(const char* fnt_file) {
int filesize = 0; auto buffer = ResourceHelper::loadFile(fnt_file);
char* buffer = file_getfilebuffer(fnt_file, filesize, true); if (buffer.empty()) {
if (!buffer) {
std::cerr << "Text: unable to load font file: " << fnt_file << '\n'; std::cerr << "Text: unable to load font file: " << fnt_file << '\n';
return; return;
} }
std::istringstream stream(std::string(buffer, filesize)); std::istringstream stream(std::string(reinterpret_cast<const char*>(buffer.data()), buffer.size()));
free(buffer);
std::string line; std::string line;
int glyph_index = 0; int glyph_index = 0;
@@ -82,27 +76,27 @@ void Text::loadFont(const char* fnt_file) {
// Elimina comentaris inline // Elimina comentaris inline
auto comment_pos = line.find('#'); auto comment_pos = line.find('#');
if (comment_pos != std::string::npos) { if (comment_pos != std::string::npos) {
line = line.substr(0, comment_pos); line.resize(comment_pos);
} }
// Parseja directives // Parseja directives
if (line.find("box_width") == 0) { if (line.starts_with("box_width")) {
sscanf(line.c_str(), "box_width %d", &box_width_); sscanf(line.c_str(), "box_width %d", &box_width_);
continue; continue;
} }
if (line.find("box_height") == 0) { if (line.starts_with("box_height")) {
sscanf(line.c_str(), "box_height %d", &box_height_); sscanf(line.c_str(), "box_height %d", &box_height_);
continue; continue;
} }
if (line.find("columns") == 0) { if (line.starts_with("columns")) {
sscanf(line.c_str(), "columns %d", &columns_); sscanf(line.c_str(), "columns %d", &columns_);
continue; continue;
} }
if (line.find("cell_spacing") == 0) { if (line.starts_with("cell_spacing")) {
sscanf(line.c_str(), "cell_spacing %d", &cell_spacing_); sscanf(line.c_str(), "cell_spacing %d", &cell_spacing_);
continue; continue;
} }
if (line.find("row_spacing") == 0) { if (line.starts_with("row_spacing")) {
sscanf(line.c_str(), "row_spacing %d", &row_spacing_); sscanf(line.c_str(), "row_spacing %d", &row_spacing_);
continue; continue;
} }
@@ -128,15 +122,14 @@ void Text::loadFont(const char* fnt_file) {
} }
void Text::loadBitmap(const char* gif_file) { void Text::loadBitmap(const char* gif_file) {
int filesize = 0; auto buffer = ResourceHelper::loadFile(gif_file);
char* buffer = file_getfilebuffer(gif_file, filesize); if (buffer.empty()) {
if (!buffer) {
std::cerr << "Text: unable to load bitmap: " << gif_file << '\n'; std::cerr << "Text: unable to load bitmap: " << gif_file << '\n';
return; return;
} }
// Extrau dimensions del header GIF (bytes 6-7 = width, 8-9 = height, little-endian) // Extrau dimensions del header GIF (bytes 6-7 = width, 8-9 = height, little-endian)
auto* raw = reinterpret_cast<unsigned char*>(buffer); auto* raw = buffer.data();
int w = raw[6] | (raw[7] << 8); int w = raw[6] | (raw[7] << 8);
int h = raw[8] | (raw[9] << 8); int h = raw[8] | (raw[9] << 8);
@@ -144,22 +137,21 @@ void Text::loadBitmap(const char* gif_file) {
Uint8* pixels = LoadGif(raw, &gw, &gh); Uint8* pixels = LoadGif(raw, &gw, &gh);
if (!pixels) { if (!pixels) {
std::cerr << "Text: unable to decode GIF: " << gif_file << '\n'; std::cerr << "Text: unable to decode GIF: " << gif_file << '\n';
free(buffer);
return; return;
} }
bitmap_width_ = w; bitmap_width_ = w;
bitmap_height_ = h; bitmap_height_ = h;
bitmap_ = pixels; bitmap_.assign(pixels, pixels + (static_cast<size_t>(w) * h));
free(pixels); // LoadGif usa malloc internament
free(buffer);
std::cout << "Text: bitmap loaded " << w << "x" << h << '\n'; std::cout << "Text: bitmap loaded " << w << "x" << h << '\n';
} }
// --- Renderitzat --- // --- Renderitzat ---
void Text::draw(Uint32* pixel_data, int x, int y, const char* text, Uint32 color) const { void Text::draw(Uint32* pixel_data, int x, int y, const char* text, Uint32 color) const {
if (!bitmap_ || !pixel_data) return; if (bitmap_.empty() || !pixel_data) return;
const char* ptr = text; const char* ptr = text;
int cursor_x = x; int cursor_x = x;
@@ -212,7 +204,7 @@ void Text::drawCentered(Uint32* pixel_data, int y, const char* text, Uint32 colo
} }
void Text::drawClipped(Uint32* pixel_data, int x, int y, const char* text, Uint32 color, int clip_x_min, int clip_x_max, int clip_y_min, int clip_y_max) const { void Text::drawClipped(Uint32* pixel_data, int x, int y, const char* text, Uint32 color, int clip_x_min, int clip_x_max, int clip_y_min, int clip_y_max) const {
if (!bitmap_ || !pixel_data) return; if (bitmap_.empty() || !pixel_data) return;
// Descart ràpid si el glifo sencer cau fora verticalment // Descart ràpid si el glifo sencer cau fora verticalment
if (y + box_height_ <= clip_y_min || y >= clip_y_max) return; if (y + box_height_ <= clip_y_min || y >= clip_y_max) return;
@@ -267,7 +259,7 @@ void Text::drawClipped(Uint32* pixel_data, int x, int y, const char* text, Uint3
} }
void Text::drawMono(Uint32* pixel_data, int x, int y, const char* text, Uint32 color, int cell_w) const { void Text::drawMono(Uint32* pixel_data, int x, int y, const char* text, Uint32 color, int cell_w) const {
if (!bitmap_ || !pixel_data) return; if (bitmap_.empty() || !pixel_data) return;
const char* ptr = text; const char* ptr = text;
int cursor_x = x; int cursor_x = x;
@@ -313,7 +305,7 @@ void Text::drawMono(Uint32* pixel_data, int x, int y, const char* text, Uint32 c
} }
void Text::drawMonoDigits(Uint32* pixel_data, int x, int y, const char* text, Uint32 color, int digit_cell_w) const { void Text::drawMonoDigits(Uint32* pixel_data, int x, int y, const char* text, Uint32 color, int digit_cell_w) const {
if (!bitmap_ || !pixel_data) return; if (bitmap_.empty() || !pixel_data) return;
const char* ptr = text; const char* ptr = text;
int cursor_x = x; int cursor_x = x;

View File

@@ -5,11 +5,11 @@
#include <cstdint> #include <cstdint>
#include <string> #include <string>
#include <unordered_map> #include <unordered_map>
#include <vector>
class Text { class Text {
public: public:
Text(const char* fnt_file, const char* gif_file); Text(const char* fnt_file, const char* gif_file);
~Text();
// Pinta texto sobre un buffer ARGB de 320x200 // Pinta texto sobre un buffer ARGB de 320x200
void draw(Uint32* pixel_data, int x, int y, const char* text, Uint32 color) const; void draw(Uint32* pixel_data, int x, int y, const char* text, Uint32 color) const;
@@ -46,7 +46,7 @@ class Text {
int cell_spacing_{0}; int cell_spacing_{0};
int row_spacing_{0}; int row_spacing_{0};
Uint8* bitmap_{nullptr}; // píxels 8-bit del GIF de la font std::vector<Uint8> bitmap_; // píxels 8-bit del GIF de la font
int bitmap_width_{0}; int bitmap_width_{0};
int bitmap_height_{0}; int bitmap_height_{0};

View File

@@ -0,0 +1,269 @@
#include "core/resources/resource_cache.hpp"
#include <SDL3/SDL.h>
#include <algorithm>
#include <cstdlib>
#include <iostream>
#include <stdexcept>
#include "core/audio/jail_audio.hpp"
#include "core/resources/resource_helper.hpp"
#include "core/resources/resource_list.hpp"
// gif.h ja s'inclou des de jdraw8.cpp i text.cpp; el seu codi no és static
// ni inline, així que no podem tornar-lo a incloure aquí. Ens fiem de les
// declaracions extern dels símbols que ens calen (linkatge C++ normal,
// igual que fa text.cpp).
extern unsigned char* LoadGif(unsigned char* data, unsigned short* w, unsigned short* h);
extern unsigned char* LoadPalette(unsigned char* data);
namespace Resource {
std::unique_ptr<Cache> Cache::instance;
void Cache::init() { instance = std::unique_ptr<Cache>(new Cache()); }
void Cache::destroy() { instance.reset(); }
auto Cache::get() -> Cache* { return instance.get(); }
namespace {
auto basename(const std::string& path) -> std::string {
auto pos = path.find_last_of("/\\");
return pos == std::string::npos ? path : path.substr(pos + 1);
}
} // namespace
auto Cache::getMusic(const std::string& name) -> JA_Music_t* {
auto it = std::ranges::find_if(musics_, [&](const auto& m) { return m.name == name; });
if (it != musics_.end()) {
return it->music.get();
}
std::cerr << "Resource::Cache: música no trobada: " << name << '\n';
throw std::runtime_error("Music not found: " + name);
}
auto Cache::getSound(const std::string& name) -> JA_Sound_t* {
auto it = std::ranges::find_if(sounds_, [&](const auto& s) { return s.name == name; });
if (it != sounds_.end()) {
return it->sound.get();
}
std::cerr << "Resource::Cache: so no trobat: " << name << '\n';
throw std::runtime_error("Sound not found: " + name);
}
auto Cache::getSurfacePixels(const std::string& name) -> const std::vector<Uint8>& {
auto it = std::ranges::find_if(surfaces_, [&](const auto& s) { return s.name == name; });
if (it != surfaces_.end()) {
return it->pixels;
}
std::cerr << "Resource::Cache: surface no trobada: " << name << '\n';
throw std::runtime_error("Surface not found: " + name);
}
auto Cache::getPaletteBytes(const std::string& name) -> const std::vector<Uint8>& {
auto it = std::ranges::find_if(surfaces_, [&](const auto& s) { return s.name == name; });
if (it != surfaces_.end()) {
return it->palette;
}
std::cerr << "Resource::Cache: paleta no trobada: " << name << '\n';
throw std::runtime_error("Palette not found: " + name);
}
auto Cache::getTextFile(const std::string& name) -> const std::vector<uint8_t>& {
auto it = std::ranges::find_if(text_files_, [&](const auto& t) { return t.name == name; });
if (it != text_files_.end()) {
return it->bytes;
}
std::cerr << "Resource::Cache: text file no trobat: " << name << '\n';
throw std::runtime_error("TextFile not found: " + name);
}
void Cache::calculateTotal() {
auto* list = List::get();
total_count_ = static_cast<int>(
list->getListByType(List::Type::MUSIC).size() +
list->getListByType(List::Type::SOUND).size() +
list->getListByType(List::Type::BITMAP).size() +
list->getListByType(List::Type::DATA).size() +
list->getListByType(List::Type::FONT).size());
loaded_count_ = 0;
}
auto Cache::getProgress() const -> float {
if (total_count_ == 0) return 1.0F;
return static_cast<float>(loaded_count_) / static_cast<float>(total_count_);
}
void Cache::beginLoad() {
calculateTotal();
stage_ = LoadStage::MUSICS;
stage_index_ = 0;
std::cout << "Resource::Cache: precarregant " << total_count_ << " assets\n";
}
auto Cache::loadStep(int budget_ms) -> bool {
if (stage_ == LoadStage::DONE) return true;
const Uint64 start_ns = SDL_GetTicksNS();
const Uint64 budget_ns = static_cast<Uint64>(budget_ms) * 1'000'000ULL;
auto* list = List::get();
while (stage_ != LoadStage::DONE) {
switch (stage_) {
case LoadStage::MUSICS: {
auto items = list->getListByType(List::Type::MUSIC);
if (stage_index_ == 0) musics_.clear();
if (stage_index_ >= items.size()) {
stage_ = LoadStage::SOUNDS;
stage_index_ = 0;
break;
}
loadOneMusic(stage_index_++);
break;
}
case LoadStage::SOUNDS: {
auto items = list->getListByType(List::Type::SOUND);
if (stage_index_ == 0) sounds_.clear();
if (stage_index_ >= items.size()) {
stage_ = LoadStage::BITMAPS;
stage_index_ = 0;
break;
}
loadOneSound(stage_index_++);
break;
}
case LoadStage::BITMAPS: {
auto items = list->getListByType(List::Type::BITMAP);
if (stage_index_ == 0) surfaces_.clear();
if (stage_index_ >= items.size()) {
stage_ = LoadStage::TEXT_FILES;
stage_index_ = 0;
break;
}
loadOneBitmap(stage_index_++);
break;
}
case LoadStage::TEXT_FILES: {
auto data_items = list->getListByType(List::Type::DATA);
auto font_items = list->getListByType(List::Type::FONT);
auto items = data_items;
items.insert(items.end(), font_items.begin(), font_items.end());
if (stage_index_ == 0) text_files_.clear();
if (stage_index_ >= items.size()) {
stage_ = LoadStage::DONE;
stage_index_ = 0;
std::cout << "Resource::Cache: precarrega completada (" << loaded_count_ << "/" << total_count_ << ")\n";
break;
}
loadOneTextFile(stage_index_++);
break;
}
case LoadStage::DONE:
break;
}
if ((SDL_GetTicksNS() - start_ns) >= budget_ns) break;
}
return stage_ == LoadStage::DONE;
}
void Cache::loadOneMusic(size_t index) {
auto items = List::get()->getListByType(List::Type::MUSIC);
const auto& path = items[index];
auto name = basename(path);
current_loading_name_ = name;
auto bytes = ResourceHelper::loadFile(path);
if (bytes.empty()) {
std::cerr << "Resource::Cache: no s'ha pogut llegir " << path << '\n';
return;
}
JA_Music_t* music = JA_LoadMusic(bytes.data(), static_cast<Uint32>(bytes.size()), path.c_str());
if (music == nullptr) {
std::cerr << "Resource::Cache: JA_LoadMusic ha fallat per " << path << '\n';
return;
}
musics_.push_back(MusicResource{.name = name, .music = std::unique_ptr<JA_Music_t, MusicDeleter>(music)});
++loaded_count_;
std::cout << " [music ] " << name << '\n';
}
void Cache::loadOneSound(size_t index) {
auto items = List::get()->getListByType(List::Type::SOUND);
const auto& path = items[index];
auto name = basename(path);
current_loading_name_ = name;
auto bytes = ResourceHelper::loadFile(path);
if (bytes.empty()) {
std::cerr << "Resource::Cache: no s'ha pogut llegir " << path << '\n';
return;
}
JA_Sound_t* sound = JA_LoadSound(bytes.data(), static_cast<uint32_t>(bytes.size()));
if (sound == nullptr) {
std::cerr << "Resource::Cache: JA_LoadSound ha fallat per " << path << '\n';
return;
}
sounds_.push_back(SoundResource{.name = name, .sound = std::unique_ptr<JA_Sound_t, SoundDeleter>(sound)});
++loaded_count_;
std::cout << " [sound ] " << name << '\n';
}
void Cache::loadOneBitmap(size_t index) {
auto items = List::get()->getListByType(List::Type::BITMAP);
const auto& path = items[index];
auto name = basename(path);
current_loading_name_ = name;
auto bytes = ResourceHelper::loadFile(path);
if (bytes.empty()) {
std::cerr << "Resource::Cache: no s'ha pogut llegir " << path << '\n';
return;
}
// Decodifica píxels.
unsigned short w = 0;
unsigned short h = 0;
unsigned char* pixels = LoadGif(bytes.data(), &w, &h);
if (pixels == nullptr) {
std::cerr << "Resource::Cache: LoadGif ha fallat per " << path << '\n';
return;
}
SurfaceResource res;
res.name = name;
res.pixels.assign(pixels, pixels + 64000);
std::free(pixels);
// Decodifica paleta des del mateix GIF (necessita una segona passada
// perquè LoadGif no exposa la paleta).
unsigned char* palette = LoadPalette(bytes.data());
if (palette != nullptr) {
res.palette.assign(palette, palette + 768);
std::free(palette);
}
surfaces_.push_back(std::move(res));
++loaded_count_;
std::cout << " [bitmap] " << name << '\n';
}
void Cache::loadOneTextFile(size_t index) {
auto data_items = List::get()->getListByType(List::Type::DATA);
auto font_items = List::get()->getListByType(List::Type::FONT);
auto items = data_items;
items.insert(items.end(), font_items.begin(), font_items.end());
const auto& path = items[index];
auto name = basename(path);
current_loading_name_ = name;
auto bytes = ResourceHelper::loadFile(path);
if (bytes.empty()) {
std::cerr << "Resource::Cache: no s'ha pogut llegir " << path << '\n';
return;
}
text_files_.push_back(TextFileResource{.name = name, .bytes = std::move(bytes)});
++loaded_count_;
std::cout << " [text ] " << name << '\n';
}
} // namespace Resource

View File

@@ -0,0 +1,73 @@
#pragma once
#include <SDL3/SDL.h>
#include <cstddef>
#include <memory>
#include <string>
#include <vector>
#include "core/resources/resource_types.hpp"
namespace Resource {
// Cache singleton: precarga + decode dels assets llistats al
// `Resource::List`. Implementa carrega incremental amb pressupost
// de temps per frame (`loadStep`) per a poder mostrar una barra de
// progrés des de l'escena `BootLoader`.
class Cache {
public:
static void init();
static void destroy();
static auto get() -> Cache*;
~Cache() = default;
Cache(const Cache&) = delete;
auto operator=(const Cache&) -> Cache& = delete;
// Getters: throw runtime_error si el nom no existeix al cache.
auto getMusic(const std::string& name) -> JA_Music_t*;
auto getSound(const std::string& name) -> JA_Sound_t*;
auto getSurfacePixels(const std::string& name) -> const std::vector<Uint8>&;
auto getPaletteBytes(const std::string& name) -> const std::vector<Uint8>&;
auto getTextFile(const std::string& name) -> const std::vector<uint8_t>&;
// Loader incremental.
void beginLoad();
auto loadStep(int budget_ms) -> bool; // true → DONE
[[nodiscard]] auto isLoadDone() const -> bool { return stage_ == LoadStage::DONE; }
[[nodiscard]] auto getProgress() const -> float; // 0.0..1.0
[[nodiscard]] auto getCurrentLoadingName() const -> const std::string& { return current_loading_name_; }
private:
Cache() = default;
enum class LoadStage {
MUSICS,
SOUNDS,
BITMAPS,
TEXT_FILES,
DONE,
};
void calculateTotal();
void loadOneMusic(size_t index);
void loadOneSound(size_t index);
void loadOneBitmap(size_t index);
void loadOneTextFile(size_t index);
std::vector<MusicResource> musics_;
std::vector<SoundResource> sounds_;
std::vector<SurfaceResource> surfaces_;
std::vector<TextFileResource> text_files_;
LoadStage stage_{LoadStage::DONE};
size_t stage_index_{0};
int total_count_{0};
int loaded_count_{0};
std::string current_loading_name_;
static std::unique_ptr<Cache> instance;
};
} // namespace Resource

View File

@@ -0,0 +1,67 @@
#include "core/resources/resource_helper.hpp"
#include <fstream>
#include <iostream>
#include "core/jail/jfile.hpp"
#include "core/resources/resource_pack.hpp"
namespace ResourceHelper {
namespace {
ResourcePack pack_;
bool pack_loaded_ = false;
bool fallback_enabled_ = true;
auto readFromDisk(const std::string& relative_path) -> std::vector<uint8_t> {
const std::string full = std::string(file_getresourcefolder()) + relative_path;
std::ifstream file(full, std::ios::binary | std::ios::ate);
if (!file) return {};
std::streamsize size = file.tellg();
file.seekg(0, std::ios::beg);
std::vector<uint8_t> data(size);
if (!file.read(reinterpret_cast<char*>(data.data()), size)) return {};
return data;
}
} // namespace
auto initializeResourceSystem(const std::string& pack_file, bool enable_fallback) -> bool {
fallback_enabled_ = enable_fallback;
pack_loaded_ = pack_.loadPack(pack_file);
if (pack_loaded_) {
std::cout << "ResourceHelper: pack loaded (" << pack_.getResourceCount()
<< " entries) from " << pack_file << '\n';
} else if (enable_fallback) {
std::cout << "ResourceHelper: no pack at " << pack_file
<< " — using filesystem fallback\n";
} else {
std::cerr << "ResourceHelper: FATAL — no pack at " << pack_file
<< " and fallback disabled\n";
return false;
}
return true;
}
void shutdownResourceSystem() {
pack_.clear();
pack_loaded_ = false;
}
auto loadFile(const std::string& relative_path) -> std::vector<uint8_t> {
if (pack_loaded_ && pack_.hasResource(relative_path)) {
return pack_.getResource(relative_path);
}
if (fallback_enabled_) {
return readFromDisk(relative_path);
}
return {};
}
auto hasPack() -> bool {
return pack_loaded_;
}
} // namespace ResourceHelper

View File

@@ -0,0 +1,27 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
// API d'alt nivell per a llegir recursos. Prova primer el pack (si està
// carregat), després cau al fitxer solt dins `file_getresourcefolder()`
// si el fallback està activat.
namespace ResourceHelper {
// Inicialitza el sistema. `pack_file` és la ruta absoluta (o relativa al
// CWD) al fitxer de recursos. `enable_fallback` permet llegir de disc
// quan el pack no conté l'entrada (útil per a Debug i WASM).
auto initializeResourceSystem(const std::string& pack_file, bool enable_fallback) -> bool;
// Allibera el pack carregat a memòria.
void shutdownResourceSystem();
// Llegeix un recurs per ruta relativa (p.ex. "gfx/logo.gif", "fonts/8bithud.fnt").
// Retorna un vector buit si no es troba.
auto loadFile(const std::string& relative_path) -> std::vector<uint8_t>;
// True si el sistema es va inicialitzar amb un pack vàlid.
[[nodiscard]] auto hasPack() -> bool;
} // namespace ResourceHelper

View File

@@ -0,0 +1,111 @@
#include "core/resources/resource_list.hpp"
#include <algorithm>
#include <iostream>
#include <stdexcept>
#include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp"
namespace Resource {
std::unique_ptr<List> List::instance;
void List::init(const std::string& yaml_path) {
instance = std::unique_ptr<List>(new List());
instance->loadFromYaml(yaml_path);
}
void List::destroy() { instance.reset(); }
auto List::get() -> List* { return instance.get(); }
void List::loadFromYaml(const std::string& yaml_path) {
auto bytes = ResourceHelper::loadFile(yaml_path);
if (bytes.empty()) {
std::cout << "Resource::List: cannot load manifest " << yaml_path << '\n';
return;
}
std::string content(bytes.begin(), bytes.end());
loadFromString(content);
}
void List::loadFromString(const std::string& yaml_content) {
try {
auto yaml = fkyaml::node::deserialize(yaml_content);
if (!yaml.contains("assets")) {
std::cout << "Resource::List: missing 'assets' root key\n";
return;
}
const auto& assets = yaml["assets"];
for (auto cat_it = assets.begin(); cat_it != assets.end(); ++cat_it) {
const auto& category_node = cat_it.value();
if (!category_node.is_mapping()) {
continue;
}
for (auto type_it = category_node.begin(); type_it != category_node.end(); ++type_it) {
auto type_str = type_it.key().get_value<std::string>();
Type type = parseAssetType(type_str);
const auto& items = type_it.value();
if (!items.is_sequence()) {
continue;
}
for (const auto& item : items) {
if (item.is_string()) {
addToMap(item.get_value<std::string>(), type);
}
}
}
}
std::cout << "Resource::List: loaded " << file_list_.size() << " assets from manifest\n";
} catch (const std::exception& e) {
std::cout << "Resource::List: YAML parse error: " << e.what() << '\n';
}
}
void List::addToMap(const std::string& path, Type type) {
auto key = basename(path);
if (file_list_.contains(key)) {
std::cout << "Resource::List: duplicate asset key '" << key << "', overwriting\n";
}
file_list_.emplace(key, Item{path, type});
}
auto List::get(const std::string& filename) const -> std::string {
auto it = file_list_.find(filename);
if (it != file_list_.end()) {
return it->second.path;
}
return "";
}
auto List::getListByType(Type type) const -> std::vector<std::string> {
std::vector<std::string> list;
for (const auto& [filename, item] : file_list_) {
if (item.type == type) {
list.push_back(item.path);
}
}
std::ranges::sort(list);
return list;
}
auto List::exists(const std::string& filename) const -> bool {
return file_list_.contains(filename);
}
auto List::parseAssetType(const std::string& type_str) -> Type {
if (type_str == "DATA") return Type::DATA;
if (type_str == "BITMAP") return Type::BITMAP;
if (type_str == "MUSIC") return Type::MUSIC;
if (type_str == "SOUND") return Type::SOUND;
if (type_str == "FONT") return Type::FONT;
throw std::runtime_error("Unknown asset type: " + type_str);
}
auto List::basename(const std::string& path) -> std::string {
auto pos = path.find_last_of("/\\");
return pos == std::string::npos ? path : path.substr(pos + 1);
}
} // namespace Resource

View File

@@ -0,0 +1,62 @@
#pragma once
#include <memory>
#include <string>
#include <unordered_map>
#include <utility>
#include <vector>
namespace Resource {
// Registre lleuger d'assets carregat des de `data/config/assets.yaml`.
// Map<basename → Item> per a lookup O(1). Cache l'utilitza per a
// iterar per categoria a l'hora de carregar.
class List {
public:
enum class Type : int {
DATA,
BITMAP,
MUSIC,
SOUND,
FONT,
SIZE,
};
static void init(const std::string& yaml_path);
static void destroy();
static auto get() -> List*;
~List() = default;
List(const List&) = delete;
auto operator=(const List&) -> List& = delete;
[[nodiscard]] auto get(const std::string& filename) const -> std::string;
[[nodiscard]] auto getListByType(Type type) const -> std::vector<std::string>;
[[nodiscard]] auto exists(const std::string& filename) const -> bool;
[[nodiscard]] auto totalCount() const -> int { return static_cast<int>(file_list_.size()); }
private:
struct Item {
std::string path; // ruta relativa al pack (ex: "music/menu.ogg")
Type type;
Item(std::string p, Type t)
: path(std::move(p)),
type(t) {}
};
List() = default;
void loadFromYaml(const std::string& yaml_path);
void loadFromString(const std::string& yaml_content);
void addToMap(const std::string& path, Type type);
[[nodiscard]] static auto parseAssetType(const std::string& type_str) -> Type;
[[nodiscard]] static auto basename(const std::string& path) -> std::string;
std::unordered_map<std::string, Item> file_list_;
static std::unique_ptr<List> instance;
};
} // namespace Resource

View File

@@ -0,0 +1,220 @@
#include "core/resources/resource_pack.hpp"
#include <algorithm>
#include <array>
#include <filesystem>
#include <fstream>
#include <iostream>
const std::string ResourcePack::DEFAULT_ENCRYPT_KEY = "AEE_RESOURCES__2026";
namespace {
constexpr const char* MAGIC = "AEE1";
constexpr uint32_t VERSION = 1;
} // namespace
ResourcePack::ResourcePack() = default;
ResourcePack::~ResourcePack() {
clear();
}
auto ResourcePack::calculateChecksum(const std::vector<uint8_t>& data) -> uint32_t {
// djb2-like hash, seed 0x12345678 (idèntic a CCAE).
uint32_t checksum = 0x12345678;
for (unsigned char b : data) {
checksum = ((checksum << 5) + checksum) + b;
}
return checksum;
}
void ResourcePack::encryptData(std::vector<uint8_t>& data, const std::string& key) {
if (key.empty()) return;
for (size_t i = 0; i < data.size(); ++i) {
data[i] ^= static_cast<uint8_t>(key[i % key.length()]);
}
}
void ResourcePack::decryptData(std::vector<uint8_t>& data, const std::string& key) {
encryptData(data, key); // XOR és simètric
}
auto ResourcePack::loadPack(const std::string& pack_file) -> bool {
std::ifstream file(pack_file, std::ios::binary);
if (!file) {
return false; // No imprimim error: el caller decideix si cal fallback
}
std::array<char, 4> header{};
file.read(header.data(), 4);
if (std::string(header.data(), 4) != MAGIC) {
std::cerr << "ResourcePack: invalid pack file format (bad magic): " << pack_file << '\n';
return false;
}
uint32_t version = 0;
file.read(reinterpret_cast<char*>(&version), sizeof(version));
if (version != VERSION) {
std::cerr << "ResourcePack: unsupported pack version: " << version << '\n';
return false;
}
uint32_t resource_count = 0;
file.read(reinterpret_cast<char*>(&resource_count), sizeof(resource_count));
resources_.clear();
resources_.reserve(resource_count);
for (uint32_t i = 0; i < resource_count; ++i) {
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);
ResourceEntry 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;
}
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));
decryptData(data_, DEFAULT_ENCRYPT_KEY);
loaded_ = true;
return true;
}
auto ResourcePack::savePack(const std::string& pack_file) -> bool {
std::ofstream file(pack_file, std::ios::binary);
if (!file) {
std::cerr << "ResourcePack: could not create pack file: " << pack_file << '\n';
return false;
}
file.write(MAGIC, 4);
uint32_t version = VERSION;
file.write(reinterpret_cast<const char*>(&version), sizeof(version));
auto resource_count = static_cast<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<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<uint8_t> encrypted = data_;
encryptData(encrypted, DEFAULT_ENCRYPT_KEY);
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 ResourcePack::addFile(const std::string& filename, const std::string& filepath) -> bool {
std::ifstream file(filepath, std::ios::binary | std::ios::ate);
if (!file) {
std::cerr << "ResourcePack: could not open file: " << filepath << '\n';
return false;
}
std::streamsize file_size = file.tellg();
file.seekg(0, std::ios::beg);
std::vector<uint8_t> file_data(file_size);
if (!file.read(reinterpret_cast<char*>(file_data.data()), file_size)) {
std::cerr << "ResourcePack: could not read file: " << filepath << '\n';
return false;
}
ResourceEntry 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 ResourcePack::addDirectory(const std::string& directory) -> bool {
if (!std::filesystem::exists(directory)) {
std::cerr << "ResourcePack: directory does not exist: " << directory << '\n';
return false;
}
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 ResourcePack::getResource(const std::string& filename) -> std::vector<uint8_t> {
auto it = resources_.find(filename);
if (it == resources_.end()) return {};
const ResourceEntry& entry = it->second;
if (entry.offset + entry.size > data_.size()) {
std::cerr << "ResourcePack: invalid resource data: " << filename << '\n';
return {};
}
std::vector<uint8_t> result(data_.begin() + entry.offset,
data_.begin() + entry.offset + entry.size);
uint32_t checksum = calculateChecksum(result);
if (checksum != entry.checksum) {
std::cerr << "ResourcePack: checksum mismatch for: " << filename << '\n';
}
return result;
}
auto ResourcePack::hasResource(const std::string& filename) const -> bool {
return resources_.contains(filename);
}
void ResourcePack::clear() {
resources_.clear();
data_.clear();
loaded_ = false;
}
auto ResourcePack::getResourceCount() const -> size_t {
return resources_.size();
}
auto ResourcePack::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;
}

View File

@@ -0,0 +1,52 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
#include <unordered_map>
#include <vector>
// Entrada d'un recurs dins el pack (format AEE, equivalent a CCAE).
struct ResourceEntry {
std::string filename;
uint64_t offset{0};
uint64_t size{0};
uint32_t checksum{0};
};
// Pack binari de recursos carregat a memòria. Formato:
// Header: "AEE1" (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)
class ResourcePack {
public:
ResourcePack();
~ResourcePack();
// I/O del fitxer
auto loadPack(const std::string& pack_file) -> bool;
auto savePack(const std::string& pack_file) -> 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) -> std::vector<uint8_t>;
[[nodiscard]] auto hasResource(const std::string& filename) const -> bool;
void clear();
[[nodiscard]] auto getResourceCount() const -> size_t;
[[nodiscard]] auto getResourceList() const -> std::vector<std::string>;
static const std::string DEFAULT_ENCRYPT_KEY;
private:
std::unordered_map<std::string, ResourceEntry> resources_;
std::vector<uint8_t> data_;
bool loaded_{false};
static auto calculateChecksum(const std::vector<uint8_t>& data) -> uint32_t;
static void encryptData(std::vector<uint8_t>& data, const std::string& key);
static void decryptData(std::vector<uint8_t>& data, const std::string& key);
};

View File

@@ -0,0 +1,61 @@
#pragma once
#include <SDL3/SDL.h>
#include <cstdint>
#include <memory>
#include <string>
#include <vector>
// Forward declarations to keep this header light.
struct JA_Music_t;
struct JA_Sound_t;
void JA_DeleteMusic(JA_Music_t* music);
void JA_DeleteSound(JA_Sound_t* sound);
namespace Resource {
struct MusicDeleter {
void operator()(JA_Music_t* music) const noexcept {
if (music != nullptr) {
JA_DeleteMusic(music);
}
}
};
struct SoundDeleter {
void operator()(JA_Sound_t* sound) const noexcept {
if (sound != nullptr) {
JA_DeleteSound(sound);
}
}
};
struct MusicResource {
std::string name;
std::unique_ptr<JA_Music_t, MusicDeleter> music;
};
struct SoundResource {
std::string name;
std::unique_ptr<JA_Sound_t, SoundDeleter> sound;
};
// Una entrada BITMAP descodifica un GIF i emmagatzema els seus
// 64000 bytes de píxels paletats + la paleta de 256 colors (768
// bytes RGB). Així `getSurface(name)` i `getPalette(name)` comparteixen
// el mateix decode.
struct SurfaceResource {
std::string name;
std::vector<Uint8> pixels; // 64000 bytes (320 * 200) paletats
std::vector<Uint8> palette; // 768 bytes (256 * R G B)
};
// Per a fitxers de text generals (locale.yaml, keys.yaml, *.fnt).
struct TextFileResource {
std::string name;
std::vector<uint8_t> bytes;
};
} // namespace Resource

View File

@@ -3,79 +3,192 @@
#include <cstring> #include <cstring>
#include <iostream> #include <iostream>
#include "core/audio/audio.hpp"
#include "core/input/gamepad.hpp" #include "core/input/gamepad.hpp"
#include "core/input/global_inputs.hpp" #include "core/input/global_inputs.hpp"
#include "core/input/key_config.hpp"
#include "core/input/key_remap.hpp" #include "core/input/key_remap.hpp"
#include "core/input/mouse.hpp" #include "core/input/mouse.hpp"
#include "core/jail/jail_audio.hpp" #include "core/jail/jdraw8.hpp"
#include "core/jail/jgame.hpp" #include "core/jail/jgame.hpp"
#include "core/jail/jinput.hpp" #include "core/jail/jinput.hpp"
#include "core/locale/locale.hpp" #include "core/locale/locale.hpp"
#include "core/rendering/menu.hpp" #include "core/rendering/menu.hpp"
#include "core/rendering/overlay.hpp" #include "core/rendering/overlay.hpp"
#include "core/rendering/screen.hpp" #include "core/rendering/screen.hpp"
#include "core/resources/resource_cache.hpp"
#include "game/info.hpp" #include "game/info.hpp"
#include "game/modulegame.hpp" #include "game/modulegame.hpp"
#include "game/modulesequence.hpp"
#include "game/options.hpp" #include "game/options.hpp"
#include "scenes/banner_scene.hpp"
#include "scenes/boot_loader_scene.hpp"
#include "scenes/credits_scene.hpp"
#include "scenes/intro_new_logo_scene.hpp"
#include "scenes/intro_scene.hpp"
#include "scenes/menu_scene.hpp"
#include "scenes/mort_scene.hpp"
#include "scenes/scene.hpp"
#include "scenes/scene_registry.hpp"
#include "scenes/secreta_scene.hpp"
#include "scenes/slides_scene.hpp"
// Cheats del joc original — declarats a jinput.cpp // Cheats del joc original — declarats a jinput.cpp
extern void JI_moveCheats(Uint8 new_key); extern void JI_moveCheats(Uint8 new_key);
Director* Director::instance_ = nullptr; std::unique_ptr<Director> Director::instance_;
Director::~Director() = default;
void Director::initGameContext() {
info::ctx.num_habitacio = Options::game.habitacio_inicial;
info::ctx.num_piramide = Options::game.piramide_inicial;
info::ctx.diners = Options::game.diners_inicial;
info::ctx.diamants = Options::game.diamants_inicial;
info::ctx.vida = Options::game.vides;
info::ctx.momies = 0;
info::ctx.nou_personatge = false;
info::ctx.pepe_activat = false;
FILE* ini = fopen("trick.ini", "rb");
if (ini != nullptr) {
info::ctx.nou_personatge = true;
fclose(ini);
}
}
std::unique_ptr<scenes::Scene> Director::createNextScene() {
// Mentre el Resource::Cache no haja acabat de precarregar, executem
// el BootLoaderScene — pinta una barra de progrés i avança la
// càrrega per pressupost de temps. Quan acaba, retorna i tornem ací
// amb el cache plenament disponible per a la resta d'escenes.
if (Resource::Cache::get() != nullptr && !Resource::Cache::get()->isLoadDone()) {
return std::make_unique<scenes::BootLoaderScene>();
}
if (game_state_ == 0) {
// Gameplay. ModuleGame és una scenes::Scene des de la Phase A.
return std::make_unique<ModuleGame>();
}
// game_state_ == 1: dispatch al registry per num_piramide. Replica
// del redirect que el vell ModuleSequence::Go() feia: si el jugador
// arriba a la Secreta (6) sense prou diners, salta als slides de
// fracàs (7) abans de buscar l'escena al registry.
if (info::ctx.num_piramide == 6 && info::ctx.diners < 200) {
info::ctx.num_piramide = 7;
}
return scenes::SceneRegistry::instance().tryCreate(info::ctx.num_piramide);
}
void Director::init() { void Director::init() {
instance_ = new Director(); instance_ = std::unique_ptr<Director>(new Director());
Gamepad::init(); Gamepad::init();
// Registre d'escenes. Cada entrada = un state_key (`num_piramide`)
// amb una factory de `scenes::Scene`. iterate() consulta aquest
// registry per a tots els states de seqüència (game_state_ == 1); si
// una clau no apareix ací, Director surt ordenadament.
auto& registry = scenes::SceneRegistry::instance();
registry.registerScene(0, [] { return std::make_unique<scenes::MenuScene>(); });
registry.registerScene(100, [] { return std::make_unique<scenes::MortScene>(); });
// BannerScene cobreix les piràmides 2..5 (el vell doBanner decideix
// pel switch intern llegint info::ctx.num_piramide).
for (int p = 2; p <= 5; ++p) {
registry.registerScene(p, [] { return std::make_unique<scenes::BannerScene>(); });
}
// SlidesScene cobreix els dos states on el vell `doSlides` s'invocava:
// - num_piramide == 1: slides narratius inicials (entrada al joc)
// - num_piramide == 7: slides de fracàs (ve del redirect 6→7 quan
// l'usuari no té prou diners per a la Secreta)
registry.registerScene(1, [] { return std::make_unique<scenes::SlidesScene>(); });
registry.registerScene(7, [] { return std::make_unique<scenes::SlidesScene>(); });
registry.registerScene(6, [] { return std::make_unique<scenes::SecretaScene>(); });
registry.registerScene(8, [] { return std::make_unique<scenes::CreditsScene>(); });
// State 255 (intro): dues variants segons `Options::game.use_new_logo`.
// La factory tria a runtime — així es pot togglar des del menú sense
// re-registrar. Les dues escenes construeixen una IntroSpritesScene
// com a sub-escena per a la part d'animacions de sprites.
registry.registerScene(255, []() -> std::unique_ptr<scenes::Scene> {
if (Options::game.use_new_logo) {
return std::make_unique<scenes::IntroNewLogoScene>();
}
return std::make_unique<scenes::IntroScene>();
});
} }
void Director::destroy() { void Director::destroy() {
Gamepad::destroy(); Gamepad::destroy();
delete instance_; instance_.reset();
instance_ = nullptr;
} }
auto Director::get() -> Director* { auto Director::get() -> Director* {
return instance_; return instance_.get();
} }
void Director::togglePause() { void Director::togglePause() {
paused_ = !paused_; paused_ = !paused_;
if (paused_) { if (paused_) {
JA_PauseMusic(); Audio::get()->pauseMusic();
} else { } else {
JA_ResumeMusic(); Audio::get()->resumeMusic();
} }
} }
void Director::run() { void Director::setup() {
// Llança el game thread // Els buffers són membres (director.hpp); només els inicialitzem.
game_thread_ = std::thread(&Director::gameThreadFunc, this); std::memset(game_frame_, 0, sizeof(game_frame_));
std::memset(presentation_buffer_, 0, sizeof(presentation_buffer_));
has_frame_ = false;
}
// Doble buffer: game_frame és el frame net del joc, presentation_buffer bool Director::iterate() {
// és el frame + overlay (es regenera cada iteració des de game_frame) if (quit_requested_) {
Uint32 game_frame[320 * 200]{}; JG_QuitSignal();
Uint32 presentation_buffer[320 * 200]{}; current_scene_.reset(); // destrueix l'escena actual ordenadament
bool has_frame = false; return false;
}
// Reinici "suau": processat al començament del frame per no manipular
// l'escena des d'una lambda del menú mentre encara s'està executant.
if (restart_requested_) {
restart_requested_ = false;
Audio::get()->stopMusic();
Audio::get()->stopAllSounds();
// Reinicialitza info::ctx des d'Options (vides, diners, diamants...)
// en lloc de ctx.reset() pla que deixaria vida=0 → jugador mort.
initGameContext();
// Força l'intro independentment de `piramide_inicial` (que pot estar
// configurat a una piràmide intermèdia per a proves ràpides).
info::ctx.num_piramide = 255;
current_scene_.reset();
game_state_ = 1; // 1 = dispatch via SceneRegistry per num_piramide
has_frame_ = false;
Menu::close();
JI_SetInputBlocked(false); // el menú ho havia bloquejat — cal desfer-ho
}
if (!context_initialized_) {
initGameContext();
context_initialized_ = true;
}
constexpr Uint32 FRAME_MS_VSYNC = 16; // ~60 FPS amb VSync constexpr Uint32 FRAME_MS_VSYNC = 16; // ~60 FPS amb VSync
constexpr Uint32 FRAME_MS_NO_VSYNC = 4; // ~250 FPS sense VSync (límit superior) constexpr Uint32 FRAME_MS_NO_VSYNC = 4; // ~250 FPS sense VSync (límit superior)
// Bucle principal del director (no-bloquejant) const Uint32 frame_start = SDL_GetTicks();
while (!game_thread_done_ && !quit_requested_) {
Uint32 frame_start = SDL_GetTicks();
handleEvents();
Gamepad::update(); Gamepad::update();
KeyRemap::update(); KeyRemap::update();
GlobalInputs::handle(); GlobalInputs::handle();
Mouse::updateCursorVisibility(); Mouse::updateCursorVisibility();
// Bombeig de l'àudio: reomple l'stream de música i para els canals
// drenats. Substituïx el callback de SDL_AddTimer de la versió
// antiga — imprescindible per al port a emscripten.
Audio::update();
// Dispara els crèdits cinematogràfics la primera vegada que el joc // Dispara els crèdits cinematogràfics la primera vegada que el joc
// arriba al menú del títol (info::num_piramide == 0). Lectura no // arriba al menú del títol (info::ctx.num_piramide == 0).
// atòmica d'un int global: race benigna, tard d'1 frame en el pitjor cas.
static bool credits_triggered = false; static bool credits_triggered = false;
if (!credits_triggered && info::num_piramide == 0) { if (!credits_triggered && info::ctx.num_piramide == 0) {
if (Options::game.show_title_credits) { if (Options::game.show_title_credits) {
Overlay::startCredits(); Overlay::startCredits();
} }
@@ -87,95 +200,128 @@ void Director::run() {
esc_blocked_ = false; esc_blocked_ = false;
} }
// Consumeix un frame nou si n'hi ha un disponible (no bloqueja). // Avança l'escena (si no estem pausats). En pausa, es manté l'escena
// Si estem en pausa, no consumim: el game thread es queda bloquejat a publishFrame. // congelada i re-presentem l'últim frame amb l'overlay fresc per
bool new_frame = false; // damunt.
if (!paused_) { if (!paused_) {
std::lock_guard lock(mutex_); // Transicions: si l'escena actual ha acabat (o s'ha senyalat
if (frame_ready_ && latest_frame_ != nullptr) { // quit), llegim el seu next state i la destruïm per crear la
memcpy(game_frame, latest_frame_, sizeof(game_frame)); // següent a continuació.
frame_ready_ = false; if (current_scene_ && (current_scene_->done() || JG_Quitting())) {
frame_consumed_ = true; game_state_ = current_scene_->nextState();
has_frame = true; current_scene_.reset();
new_frame = true;
} }
// Si no hi ha escena activa, construeix la pròxima segons
// game_state_ i info::ctx. Si és impossible (game_state_ == -1,
// quit, o state no registrat), eixim del loop.
if (!current_scene_) {
if (game_state_ == -1 || JG_Quitting()) return false;
current_scene_ = createNextScene();
if (!current_scene_) return false;
current_scene_->onEnter();
last_tick_ms_ = SDL_GetTicks();
} }
if (new_frame) {
frame_consumed_cv_.notify_one(); // desbloqueja el joc // Tick de l'escena. JI_Update refresca key_pressed/any_key; el
// delta_ms és el temps real transcorregut des de l'últim tick.
JI_Update();
const Uint32 now = SDL_GetTicks();
const int delta_ms = static_cast<int>(now - last_tick_ms_);
last_tick_ms_ = now;
current_scene_->tick(delta_ms);
// Converteix `screen` indexat → `pixel_data` ARGB amb la paleta
// actual. JD8_Flip ja no fa yield (Phase B.2 eliminà els fibers);
// ara només omple el framebuffer perquè el Director l'aprofite.
JD8_Flip();
std::memcpy(game_frame_, JD8_GetFramebuffer(), sizeof(game_frame_));
has_frame_ = true;
} }
// Presenta sempre: parteix del frame net del joc, afegeix overlay i envia // Presenta sempre: parteix del frame net del joc, afegeix overlay i envia
if (has_frame) { if (has_frame_) {
memcpy(presentation_buffer, game_frame, sizeof(presentation_buffer)); std::memcpy(presentation_buffer_, game_frame_, sizeof(presentation_buffer_));
Screen::get()->present(presentation_buffer); Screen::get()->present(presentation_buffer_);
} }
// Límit de framerate segons VSync // Límit de framerate segons VSync.
Uint32 target_ms = Options::video.vsync ? FRAME_MS_VSYNC : FRAME_MS_NO_VSYNC; // Nota: quan el runtime posseïx el main loop (SDL_AppIterate /
Uint32 elapsed = SDL_GetTicks() - frame_start; // emscripten), aquest SDL_Delay no és ideal. Fase 7 afegirà un mode
// que es basa en el timing intern de SDL en lloc del delay explícit.
const Uint32 target_ms = Options::video.vsync ? FRAME_MS_VSYNC : FRAME_MS_NO_VSYNC;
const Uint32 elapsed = SDL_GetTicks() - frame_start;
if (elapsed < target_ms) { if (elapsed < target_ms) {
SDL_Delay(target_ms - elapsed); SDL_Delay(target_ms - elapsed);
} }
return true;
} }
// Assegura que el game thread ix (despertar-lo per si està esperant) void Director::teardown() {
quit_requested_ = true; // Senyal de quit i descàrrega ordenada de l'escena en curs. Els
// destructors de cada escena són no-bloquejants — ja no fan fades
// bloquejants. La resta de cleanup la gestiona `destroy()`.
JG_QuitSignal(); JG_QuitSignal();
{ current_scene_.reset();
std::lock_guard lock(mutex_);
frame_consumed_ = true;
}
frame_consumed_cv_.notify_all();
if (game_thread_.joinable()) {
game_thread_.join();
}
} }
void Director::handleEvents() { void Director::run() {
setup();
while (true) {
pollAllEvents();
if (!iterate()) break;
}
teardown();
}
void Director::pollAllEvents() {
SDL_Event event; SDL_Event event;
while (SDL_PollEvent(&event)) { while (SDL_PollEvent(&event)) {
handleEvent(event);
}
}
void Director::handleEvent(const SDL_Event& event) {
if (event.type == SDL_EVENT_QUIT) { if (event.type == SDL_EVENT_QUIT) {
JG_QuitSignal(); JG_QuitSignal();
requestQuit(); requestQuit();
} }
// Hot-plug de gamepad // Hot-plug de gamepad (a Emscripten els dispositius web entren com
if (event.type == SDL_EVENT_GAMEPAD_ADDED || event.type == SDL_EVENT_GAMEPAD_REMOVED) { // JOYSTICK_ADDED/REMOVED perquè SDL no reconeix el GUID)
if (event.type == SDL_EVENT_GAMEPAD_ADDED || event.type == SDL_EVENT_GAMEPAD_REMOVED ||
event.type == SDL_EVENT_JOYSTICK_ADDED || event.type == SDL_EVENT_JOYSTICK_REMOVED) {
Gamepad::handleEvent(event); Gamepad::handleEvent(event);
continue; return;
}
// Salta els crèdits amb qualsevol tecla; no deixem que arribi al joc
if (event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat && Overlay::creditsActive()) {
Overlay::cancelCredits();
continue;
} }
// Empassar-se el KEY_UP de qualsevol tecla que el menú va consumir en KEY_DOWN // Empassar-se el KEY_UP de qualsevol tecla que el menú va consumir en KEY_DOWN
if (event.type == SDL_EVENT_KEY_UP && event.key.scancode >= 0 && if (event.type == SDL_EVENT_KEY_UP && event.key.scancode >= 0 &&
event.key.scancode < SDL_SCANCODE_COUNT && menu_keys_held_[event.key.scancode]) { event.key.scancode < SDL_SCANCODE_COUNT && menu_keys_held_[event.key.scancode]) {
menu_keys_held_[event.key.scancode] = false; menu_keys_held_[event.key.scancode] = false;
continue; return;
} }
// Captura de tecla (remapeig al menú): intercepta KEY_DOWN abans de tot // Captura de tecla (remapeig al menú): intercepta KEY_DOWN abans de tot
if (Menu::isCapturing() && event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat) { if (Menu::isCapturing() && event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat) {
Menu::captureKey(event.key.scancode); Menu::captureKey(event.key.scancode);
menu_keys_held_[event.key.scancode] = true; menu_keys_held_[event.key.scancode] = true;
continue; return;
} }
// Pausa: F11 (o tecla configurada) pausa/reprén la simulació // Pausa: F11 (o tecla configurada) pausa/reprén la simulació.
// No mostrem notificació — l'indicador persistent "Pausa" a la cantonada
// superior dreta (pintat per Overlay) ja comunica l'estat.
if (event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat && if (event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat &&
event.key.scancode == Options::keys_gui.pause_toggle) { event.key.scancode == KeyConfig::scancode("pause_toggle")) {
togglePause(); togglePause();
Overlay::showNotification(paused_ ? Locale::get("notifications.pause") : Locale::get("notifications.resume"));
menu_keys_held_[event.key.scancode] = true; menu_keys_held_[event.key.scancode] = true;
continue; return;
} }
// Menú: F12 (o tecla configurada) obre/tanca el menú flotant // Menú: F12 (o tecla configurada) obre/tanca el menú flotant
if (event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat && if (event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat &&
event.key.scancode == Options::keys_gui.menu_toggle) { event.key.scancode == KeyConfig::scancode("menu_toggle")) {
Menu::toggle(); Menu::toggle();
JI_SetInputBlocked(Menu::isOpen()); JI_SetInputBlocked(Menu::isOpen());
menu_keys_held_[event.key.scancode] = true; menu_keys_held_[event.key.scancode] = true;
continue; return;
} }
// Si el menú està obert, consumeix tot l'input de teclat // Si el menú està obert, consumeix tot l'input de teclat
if (Menu::isOpen() && event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat) { if (Menu::isOpen() && event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat) {
@@ -192,15 +338,23 @@ void Director::handleEvents() {
} }
} }
menu_keys_held_[event.key.scancode] = true; menu_keys_held_[event.key.scancode] = true;
continue; return;
} }
if (Menu::isOpen() && event.type == SDL_EVENT_KEY_UP) { if (Menu::isOpen() && event.type == SDL_EVENT_KEY_UP) {
continue; // no deixem passar KEY_UP al joc tampoc return; // no deixem passar KEY_UP al joc tampoc
}
// Salta els crèdits amb qualsevol tecla que arribe al joc. Es fa DESPRÉS
// del toggle del menú/pausa i del handling del menú obert — així F12 i
// SELECT (gamepad) obrin el menú sense cancel·lar els crèdits, i la
// navegació per dins del menú tampoc els anul·la.
if (event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat && Overlay::creditsActive()) {
Overlay::cancelCredits();
return;
} }
// Allibera el bloqueig d'ESC quan l'usuari la deixa anar // Allibera el bloqueig d'ESC quan l'usuari la deixa anar
if (event.type == SDL_EVENT_KEY_UP && event.key.scancode == SDL_SCANCODE_ESCAPE && esc_swallow_until_release_) { if (event.type == SDL_EVENT_KEY_UP && event.key.scancode == SDL_SCANCODE_ESCAPE && esc_swallow_until_release_) {
esc_swallow_until_release_ = false; esc_swallow_until_release_ = false;
continue; return;
} }
// ESC: interceptem KEY_DOWN per bloquejar-la ABANS que el joc la veja per polling // ESC: interceptem KEY_DOWN per bloquejar-la ABANS que el joc la veja per polling
if (event.type == SDL_EVENT_KEY_DOWN && event.key.scancode == SDL_SCANCODE_ESCAPE && !event.key.repeat) { if (event.type == SDL_EVENT_KEY_DOWN && event.key.scancode == SDL_SCANCODE_ESCAPE && !event.key.repeat) {
@@ -213,33 +367,25 @@ void Director::handleEvents() {
esc_blocked_ = false; esc_blocked_ = false;
key_pressed_ = true; key_pressed_ = true;
JG_QuitSignal(); JG_QuitSignal();
// Si estem en pausa, la desactivem (sense reprendre la música, // Si estem en pausa, la desactivem: el fiber del joc està
// estem eixint): el game thread està bloquejat a publishFrame // congelat i necessita ser reprès per veure la senyal de
// i necessita que Director consumeixca frames per despertar-lo // quit i poder tornar de forma natural.
// i poder veure la senyal de quit.
paused_ = false; paused_ = false;
} }
continue; // no processa més aquest event return; // no processa més aquest event
} }
if (event.type == SDL_EVENT_KEY_UP) { if (event.type == SDL_EVENT_KEY_UP) {
if (event.key.scancode == SDL_SCANCODE_ESCAPE) { if (event.key.scancode == SDL_SCANCODE_ESCAPE) {
// Ja processat a KEY_DOWN, només deixem netejar el bloqueig // Ja processat a KEY_DOWN, només deixem netejar el bloqueig
// quan l'overlay faça timeout // quan l'overlay faça timeout
continue; return;
} else { } else {
// Comprova si és una tecla GUI (no passa al joc) // Comprova si és una tecla d'UI registrada (no passa al joc).
// KeyConfig::isGuiKey cobreix totes les tecles GUI a la vegada,
// incloent pause_toggle i menu_toggle (defensa en profunditat:
// aquestes ja s'haurien hagut de menjar al swallow d'amunt).
const auto sc = event.key.scancode; const auto sc = event.key.scancode;
const bool is_gui_key = (sc == Options::keys_gui.dec_zoom || if (!KeyConfig::isGuiKey(sc)) {
sc == Options::keys_gui.inc_zoom ||
sc == Options::keys_gui.fullscreen ||
sc == Options::keys_gui.toggle_shader ||
sc == Options::keys_gui.toggle_aspect_ratio ||
sc == Options::keys_gui.toggle_supersampling ||
sc == Options::keys_gui.next_shader ||
sc == Options::keys_gui.next_shader_preset ||
sc == Options::keys_gui.toggle_stretch_filter ||
sc == Options::keys_gui.toggle_render_info);
if (!is_gui_key) {
key_pressed_ = true; key_pressed_ = true;
JI_moveCheats(sc); JI_moveCheats(sc);
} }
@@ -247,72 +393,16 @@ void Director::handleEvents() {
} }
Mouse::handleEvent(event); Mouse::handleEvent(event);
} }
}
void Director::publishFrame(Uint32* pixels) {
{
std::lock_guard lock(mutex_);
latest_frame_ = pixels;
frame_ready_ = true;
frame_consumed_ = false;
}
frame_produced_cv_.notify_one();
// Espera que el director consumeixca el frame
{
std::unique_lock lock(mutex_);
frame_consumed_cv_.wait(lock, [this] {
return frame_consumed_ || quit_requested_;
});
}
}
void Director::requestQuit() { void Director::requestQuit() {
quit_requested_ = true; quit_requested_ = true;
JG_QuitSignal(); JG_QuitSignal();
frame_consumed_cv_.notify_all(); }
frame_produced_cv_.notify_all();
void Director::requestRestart() {
restart_requested_ = true;
} }
auto Director::consumeKeyPressed() -> bool { auto Director::consumeKeyPressed() -> bool {
return key_pressed_.exchange(false); return key_pressed_.exchange(false);
} }
void Director::gameThreadFunc() {
info::num_habitacio = Options::game.habitacio_inicial;
info::num_piramide = Options::game.piramide_inicial;
info::diners = 0;
info::diamants = 0;
info::vida = Options::game.vides;
info::momies = 0;
info::nou_personatge = false;
info::pepe_activat = false;
FILE* ini = fopen("trick.ini", "rb");
if (ini != nullptr) {
info::nou_personatge = true;
fclose(ini);
}
int gameState = 1;
while (gameState != -1 && !quit_requested_) {
switch (gameState) {
case 0: {
auto* moduleGame = new ModuleGame();
gameState = moduleGame->Go();
delete moduleGame;
break;
}
case 1: {
auto* moduleSequence = new ModuleSequence();
gameState = moduleSequence->Go();
delete moduleSequence;
break;
}
}
}
game_thread_done_ = true;
// Despertar el director per si esperava un frame
frame_produced_cv_.notify_all();
}

View File

@@ -3,30 +3,43 @@
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <atomic> #include <atomic>
#include <condition_variable>
#include <cstdint> #include <cstdint>
#include <mutex> #include <memory>
#include <thread>
// El Director és el thread principal que controla la presentació i els inputs. #include "scenes/scene.hpp"
// Executa el joc en un thread secundari (game thread) com si fos una "fibra emulada":
// el joc produeix un frame, es bloqueja a JD8_Flip(), i el director el presenta // El Director és l'únic thread del runtime. Cada iterate() fa input →
// abans de donar-li via per produir el següent. // tick de l'escena actual → JD8_Flip → overlay → present → sleep al frame
// target. Totes les escenes (`scenes::Scene` i `ModuleGame`) són
// tick-based i no bloquegen — no hi ha fibers, mutex ni condition_variable.
// Compatible amb SDL_AppIterate i amb el futur port a emscripten.
class Director { class Director {
public: public:
static void init(); static void init();
static void destroy(); static void destroy();
static auto get() -> Director*; static auto get() -> Director*;
// Bucle principal del director. Crida des de main(). // Bucle principal clàssic (build natiu sense SDL_MAIN_USE_CALLBACKS).
// Internament crida setup() + bucle d'iterate() + teardown(). Crida des de main().
void run(); void run();
// Invocat pel game thread des de JD8_Flip(). Bloqueja fins que el director // Punts d'entrada compatibles amb SDL_AppInit / SDL_AppIterate /
// consumeix el frame i dona via per produir el següent. // SDL_AppEvent / SDL_AppQuit. Permeten que el Director siga driven
void publishFrame(Uint32* pixels); // per l'event loop de SDL3 en lloc d'un bucle propi — imprescindible
// per al port a emscripten, on el runtime posseïx el main loop.
void setup();
bool iterate(); // torna false quan el joc vol eixir
void teardown();
void handleEvent(const SDL_Event& event);
// Demana l'eixida (ex: segona pulsació d'ESC o SDL_QUIT) // Demana l'eixida (ex: segona pulsació d'ESC o SDL_QUIT)
void requestQuit(); void requestQuit();
auto isQuitRequested() const -> bool { return quit_requested_; }
// Demana un reinici "suau": para música i sons, reseteja info::ctx i
// torna a l'intro (state 255). Es processa al començament del pròxim
// iterate() per evitar manipular l'escena des d'una lambda del menú.
void requestRestart();
// Consumeix el flag de "tecla polsada" (com l'antic JI_AnyKey) // Consumeix el flag de "tecla polsada" (com l'antic JI_AnyKey)
auto consumeKeyPressed() -> bool; auto consumeKeyPressed() -> bool;
@@ -34,30 +47,44 @@ class Director {
// Indica si ESC està bloquejada (el joc no l'ha de veure) // Indica si ESC està bloquejada (el joc no l'ha de veure)
auto isEscBlocked() const -> bool { return esc_blocked_ || esc_swallow_until_release_; } auto isEscBlocked() const -> bool { return esc_blocked_ || esc_swallow_until_release_; }
// Pausa: bloqueja el consum de frames del game thread + pausa la música // Pausa: mentre està activa, iterate() no avança l'escena — es
// continua presentant el darrer frame amb overlay fresc.
void togglePause(); void togglePause();
auto isPaused() const -> bool { return paused_; } auto isPaused() const -> bool { return paused_; }
public:
~Director();
private: private:
Director() = default; Director() = default;
~Director() = default;
static Director* instance_; static std::unique_ptr<Director> instance_;
void gameThreadFunc(); void pollAllEvents(); // drenatge amb SDL_PollEvent, només per al bucle natiu
void handleEvents();
std::thread game_thread_; // Inicialitza info::ctx a partir de Options::game.* i comprova trick.ini.
std::mutex mutex_; // Es crida una sola vegada des d'iterate() a la primera invocació.
std::condition_variable frame_produced_cv_; void initGameContext();
std::condition_variable frame_consumed_cv_; // Construeix l'escena apropiada segons game_state_ i info::ctx.
// Retorna nullptr si l'state actual no té escena registrada (bug).
std::unique_ptr<scenes::Scene> createNextScene();
Uint32* latest_frame_{nullptr}; // Buffers persistents entre iteracions. Abans eren locals a run(),
bool frame_ready_{false}; // ara són membres perquè iterate() els pot reutilitzar sense tornar-los
bool frame_consumed_{true}; // a reservar en cada crida del callback.
Uint32 game_frame_[320 * 200]{};
Uint32 presentation_buffer_[320 * 200]{};
bool has_frame_{false};
// Estat de l'escena actual. Abans vivia al stack del GameFiber; des
// de la Phase B.2 de la migració viu directament al Director.
std::unique_ptr<scenes::Scene> current_scene_;
int game_state_{1}; // 0 = gameplay (ModuleGame), 1 = via SceneRegistry, -1 = quit
Uint32 last_tick_ms_{0};
bool context_initialized_{false};
std::atomic<bool> quit_requested_{false}; std::atomic<bool> quit_requested_{false};
std::atomic<bool> game_thread_done_{false}; std::atomic<bool> restart_requested_{false};
std::atomic<bool> key_pressed_{false}; std::atomic<bool> key_pressed_{false};
std::atomic<bool> esc_blocked_{false}; std::atomic<bool> esc_blocked_{false};
std::atomic<bool> paused_{false}; std::atomic<bool> paused_{false};

4
source/external/.clang-tidy vendored Normal file
View File

@@ -0,0 +1,4 @@
# source/external/.clang-tidy
Checks: '-*'
WarningsAsErrors: ''
HeaderFilterRegex: ''

View File

@@ -8,26 +8,12 @@ Bola::Bola(JD8_Surface gfx, Prota* sam)
: Sprite(gfx) { : Sprite(gfx) {
this->sam = sam; this->sam = sam;
this->entitat = (Entitat*)malloc(sizeof(Entitat)); entitat.frames.reserve(2);
// Frames entitat.frames.push_back({30, 155, 15, 15});
this->entitat->num_frames = 2; entitat.frames.push_back({45, 155, 15, 15});
this->entitat->frames = (Frame*)malloc(this->entitat->num_frames * sizeof(Frame));
this->entitat->frames[0].w = 15;
this->entitat->frames[0].h = 15;
this->entitat->frames[0].x = 30;
this->entitat->frames[0].y = 155;
this->entitat->frames[1].w = 15;
this->entitat->frames[1].h = 15;
this->entitat->frames[1].x = 45;
this->entitat->frames[1].y = 155;
// Animacions entitat.animacions.resize(1);
this->entitat->num_animacions = 1; entitat.animacions[0].frames = {0, 1};
this->entitat->animacions = (Animacio*)malloc(this->entitat->num_animacions * sizeof(Animacio));
this->entitat->animacions[0].num_frames = 2;
this->entitat->animacions[0].frames = (Uint8*)malloc(2);
this->entitat->animacions[0].frames[0] = 0;
this->entitat->animacions[0].frames[1] = 1;
this->cur_frame = 0; this->cur_frame = 0;
this->o = 0; this->o = 0;
@@ -50,14 +36,14 @@ void Bola::update() {
// Augmentem el frame // Augmentem el frame
if (JG_GetCycleCounter() % this->cycles_per_frame == 0) { if (JG_GetCycleCounter() % this->cycles_per_frame == 0) {
this->cur_frame++; this->cur_frame++;
if (this->cur_frame == this->entitat->animacions[this->o].num_frames) this->cur_frame = 0; if (this->cur_frame == entitat.animacions[this->o].frames.size()) this->cur_frame = 0;
} }
// Comprovem si ha tocat a Sam // Comprovem si ha tocat a Sam
if (this->x > (this->sam->x - 7) && this->x < (this->sam->x + 7) && this->y > (this->sam->y - 7) && this->y < (this->sam->y + 7)) { if (this->x > (this->sam->x - 7) && this->x < (this->sam->x + 7) && this->y > (this->sam->y - 7) && this->y < (this->sam->y + 7)) {
this->contador = 200; this->contador = 200;
info::vida--; info::ctx.vida--;
if (info::vida == 0) this->sam->o = 5; if (info::ctx.vida == 0) this->sam->o = 5;
} }
} else { } else {
this->contador--; this->contador--;

View File

@@ -6,9 +6,9 @@
class Bola : public Sprite { class Bola : public Sprite {
public: public:
Bola(JD8_Surface gfx, Prota* sam); explicit Bola(JD8_Surface gfx, Prota* sam);
void draw(); void draw() override;
void update(); void update();
protected: protected:

View File

@@ -2,21 +2,7 @@
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
// Tecles GUI (capa de presentació — finestra, zoom, shaders, etc.) // Tecles GUI: viuen a data/input/keys.yaml (font única — KeyConfig).
namespace Defaults::KeysGUI {
constexpr SDL_Scancode DEC_ZOOM = SDL_SCANCODE_F1;
constexpr SDL_Scancode INC_ZOOM = SDL_SCANCODE_F2;
constexpr SDL_Scancode FULLSCREEN = SDL_SCANCODE_F3;
constexpr SDL_Scancode TOGGLE_SHADER = SDL_SCANCODE_F4;
constexpr SDL_Scancode TOGGLE_ASPECT_RATIO = SDL_SCANCODE_F5;
constexpr SDL_Scancode TOGGLE_SUPERSAMPLING = SDL_SCANCODE_F6;
constexpr SDL_Scancode NEXT_SHADER = SDL_SCANCODE_F7;
constexpr SDL_Scancode NEXT_SHADER_PRESET = SDL_SCANCODE_F8;
constexpr SDL_Scancode TOGGLE_STRETCH_FILTER = SDL_SCANCODE_F9;
constexpr SDL_Scancode TOGGLE_RENDER_INFO = SDL_SCANCODE_F10;
constexpr SDL_Scancode PAUSE_TOGGLE = SDL_SCANCODE_F11;
constexpr SDL_Scancode MENU_TOGGLE = SDL_SCANCODE_F12;
} // namespace Defaults::KeysGUI
// Tecles de joc (moviment del personatge, accions) // Tecles de joc (moviment del personatge, accions)
namespace Defaults::KeysGame { namespace Defaults::KeysGame {
@@ -31,12 +17,11 @@ namespace Defaults::Video {
constexpr bool GPU_ACCELERATION = true; constexpr bool GPU_ACCELERATION = true;
constexpr bool SHADER_ENABLED = false; constexpr bool SHADER_ENABLED = false;
constexpr bool SUPERSAMPLING = false; constexpr bool SUPERSAMPLING = false;
constexpr bool INTEGER_SCALE = true;
constexpr bool VSYNC = true; constexpr bool VSYNC = true;
constexpr bool ASPECT_RATIO_4_3 = false; // CRT original estira 200→240 constexpr bool ASPECT_RATIO_4_3 = false; // CRT original estira 200→240
constexpr bool STRETCH_FILTER_LINEAR = false; // Filtre per a l'estirament 4:3 (false=NEAREST)
constexpr int DOWNSCALE_ALGO = 1; // 0=bilinear, 1=Lanczos2, 2=Lanczos3 constexpr int DOWNSCALE_ALGO = 1; // 0=bilinear, 1=Lanczos2, 2=Lanczos3
constexpr bool LINEAR_UPSCALE = false; constexpr int INTERNAL_RESOLUTION = 1; // Multiplicador enter de la textura font abans del pipeline
// TextureFilter i ScalingMode viuen a Options (requereixen #include, evitem dependència circular).
} // namespace Defaults::Video } // namespace Defaults::Video
namespace Defaults::Audio { namespace Defaults::Audio {
@@ -57,6 +42,9 @@ namespace Defaults::Game {
constexpr int HABITACIO_INICIAL = 1; constexpr int HABITACIO_INICIAL = 1;
constexpr int PIRAMIDE_INICIAL = 255; constexpr int PIRAMIDE_INICIAL = 255;
constexpr int VIDES = 5; constexpr int VIDES = 5;
constexpr int DIAMANTS_INICIAL = 0;
constexpr int DINERS_INICIAL = 0;
constexpr bool USE_NEW_LOGO = true; constexpr bool USE_NEW_LOGO = true;
constexpr bool SHOW_TITLE_CREDITS = true; constexpr bool SHOW_TITLE_CREDITS = true;
constexpr bool SHOW_PRELOAD = false;
} // namespace Defaults::Game } // namespace Defaults::Game

View File

@@ -3,7 +3,7 @@
// Textos // Textos
namespace Texts { namespace Texts {
constexpr const char* WINDOW_TITLE = "© 2000 Aventures en Egipte — JailDesigner"; constexpr const char* WINDOW_TITLE = "© 2000 Aventures en Egipte — JailDesigner";
constexpr const char* VERSION = "1.11"; constexpr const char* VERSION = "1.2";
} // namespace Texts } // namespace Texts
// Resolución del juego // Resolución del juego

View File

@@ -6,33 +6,20 @@
Engendro::Engendro(JD8_Surface gfx, Uint16 x, Uint16 y) Engendro::Engendro(JD8_Surface gfx, Uint16 x, Uint16 y)
: Sprite(gfx) { : Sprite(gfx) {
this->entitat = (Entitat*)malloc(sizeof(Entitat)); entitat.frames.reserve(4);
// Frames for (int py = 50; py <= 65; py += 15) {
this->entitat->num_frames = 4; for (int px = 225; px <= 240; px += 15) {
this->entitat->frames = (Frame*)malloc(this->entitat->num_frames * sizeof(Frame)); Frame f;
f.w = 15;
Uint8 frame = 0; f.h = 15;
for (int y = 50; y <= 65; y += 15) { f.x = px;
for (int x = 225; x <= 240; x += 15) { f.y = py;
this->entitat->frames[frame].w = 15; entitat.frames.push_back(f);
this->entitat->frames[frame].h = 15;
this->entitat->frames[frame].x = x;
this->entitat->frames[frame].y = y;
frame++;
} }
} }
// Animacions entitat.animacions.resize(1);
this->entitat->num_animacions = 1; entitat.animacions[0].frames = {0, 1, 2, 3, 2, 1};
this->entitat->animacions = (Animacio*)malloc(this->entitat->num_animacions * sizeof(Animacio));
this->entitat->animacions[0].num_frames = 6;
this->entitat->animacions[0].frames = (Uint8*)malloc(6);
this->entitat->animacions[0].frames[0] = 0;
this->entitat->animacions[0].frames[1] = 1;
this->entitat->animacions[0].frames[2] = 2;
this->entitat->animacions[0].frames[3] = 3;
this->entitat->animacions[0].frames[4] = 2;
this->entitat->animacions[0].frames[5] = 1;
this->cur_frame = 0; this->cur_frame = 0;
this->vida = 18; this->vida = 18;
@@ -42,16 +29,12 @@ Engendro::Engendro(JD8_Surface gfx, Uint16 x, Uint16 y)
this->cycles_per_frame = 30; this->cycles_per_frame = 30;
} }
void Engendro::draw() {
Sprite::draw();
}
bool Engendro::update() { bool Engendro::update() {
bool mort = false; bool mort = false;
if (JG_GetCycleCounter() % this->cycles_per_frame == 0) { if (JG_GetCycleCounter() % this->cycles_per_frame == 0) {
this->cur_frame++; this->cur_frame++;
if (this->cur_frame == this->entitat->animacions[this->o].num_frames) this->cur_frame = 0; if (this->cur_frame == entitat.animacions[this->o].frames.size()) this->cur_frame = 0;
this->vida--; this->vida--;
} }

View File

@@ -4,9 +4,8 @@
class Engendro : public Sprite { class Engendro : public Sprite {
public: public:
Engendro(JD8_Surface gfx, Uint16 x, Uint16 y); explicit Engendro(JD8_Surface gfx, Uint16 x, Uint16 y);
void draw();
bool update(); bool update();
protected: protected:

View File

@@ -1,13 +1,4 @@
#include "game/info.hpp" #include "game/info.hpp"
namespace info { // La instància `info::ctx` està definida com a `inline` al header;
int num_piramide; // aquest fitxer es manté per a si cal afegir lògica addicional més endavant.
int num_habitacio;
int diners;
int diamants;
int vida;
int momies;
int engendros;
bool nou_personatge;
bool pepe_activat;
}; // namespace info

View File

@@ -1,13 +1,24 @@
#pragma once #pragma once
namespace info { namespace info {
extern int num_piramide;
extern int num_habitacio; struct GameContext {
extern int diners; int num_piramide = 0;
extern int diamants; int num_habitacio = 0;
extern int vida; int diners = 0;
extern int momies; int diamants = 0;
extern int engendros; int vida = 0;
extern bool nou_personatge; int momies = 0;
extern bool pepe_activat; int engendros = 0;
}; // namespace info bool nou_personatge = false;
bool pepe_activat = false;
void reset() { *this = GameContext{}; }
};
// Instància única de l'estat del joc. Reemplaça les variables soltes del
// namespace `info::` per una struct encapsulada. A Fase 5 (single-threaded)
// es podrà passar per referència als mòduls en lloc d'accedir via singleton.
inline GameContext ctx;
} // namespace info

View File

@@ -26,7 +26,7 @@ Mapa::~Mapa(void) {
} }
void Mapa::draw() { void Mapa::draw() {
if (info::num_piramide != 4) { if (info::ctx.num_piramide != 4) {
switch (sam->o) { switch (sam->o) {
case 0: // Down case 0: // Down
JD8_BlitCKToSurface(sam->x, sam->y, this->gfx, 15, 125 + sam->frame_pejades, 15, 1, this->fondo, 255); JD8_BlitCKToSurface(sam->x, sam->y, this->gfx, 15, 125 + sam->frame_pejades, 15, 1, this->fondo, 255);
@@ -88,7 +88,7 @@ bool Mapa::novaMomia() {
void Mapa::preparaFondoEstatic() { void Mapa::preparaFondoEstatic() {
// Prepara el fondo est<73>tic de l'habitaci<63> // Prepara el fondo est<73>tic de l'habitaci<63>
this->fondo = JD8_NewSurface(); this->fondo = JD8_NewSurface();
if (info::num_piramide == 6) { if (info::ctx.num_piramide == 6) {
JD8_BlitToSurface(9, 2, this->gfx, 227, 185, 92, 7, this->fondo); // Text "SECRETA" JD8_BlitToSurface(9, 2, this->gfx, 227, 185, 92, 7, this->fondo); // Text "SECRETA"
} else { } else {
JD8_BlitToSurface(9, 2, this->gfx, 60, 185, 39, 7, this->fondo); // Text "NIVELL" JD8_BlitToSurface(9, 2, this->gfx, 60, 185, 39, 7, this->fondo); // Text "NIVELL"
@@ -96,12 +96,12 @@ void Mapa::preparaFondoEstatic() {
} }
JD8_BlitToSurface(130, 2, this->gfx, 225, 192, 19, 8, this->fondo); // Montonet de monedes + signe '=' JD8_BlitToSurface(130, 2, this->gfx, 225, 192, 19, 8, this->fondo); // Montonet de monedes + signe '='
JD8_BlitToSurface(220, 2, this->gfx, 160, 185, 48, 7, this->fondo); // Text "ENERGIA" JD8_BlitToSurface(220, 2, this->gfx, 160, 185, 48, 7, this->fondo); // Text "ENERGIA"
if (info::diners >= 200) JD8_BlitToSurface(175, 3, this->gfx, 60, 193, 7, 6, this->fondo); if (info::ctx.diners >= 200) JD8_BlitToSurface(175, 3, this->gfx, 60, 193, 7, 6, this->fondo);
// Pinta taulells // Pinta taulells
for (int y = 0; y < 11; y++) { for (int y = 0; y < 11; y++) {
for (int x = 0; x < 19; x++) { for (int x = 0; x < 19; x++) {
switch (info::num_piramide) { switch (info::ctx.num_piramide) {
case 1: case 1:
JD8_BlitToSurface(20 + (x * 15), 30 + (y * 15), this->gfx, 0, 80, 15, 15, this->fondo); JD8_BlitToSurface(20 + (x * 15), 30 + (y * 15), this->gfx, 0, 80, 15, 15, this->fondo);
break; break;
@@ -145,7 +145,7 @@ void Mapa::preparaFondoEstatic() {
// Pinta la porta // Pinta la porta
JD8_BlitCKToSurface(150, 18, this->gfx, 0, 143, 15, 12, this->fondo, 255); JD8_BlitCKToSurface(150, 18, this->gfx, 0, 143, 15, 12, this->fondo, 255);
if (info::num_piramide == 2) { if (info::ctx.num_piramide == 2) {
JD8_BlitToSurface(5, 100, this->gfx, 30, 140, 15, 15, this->fondo); JD8_BlitToSurface(5, 100, this->gfx, 30, 140, 15, 15, this->fondo);
} }
} }
@@ -157,9 +157,9 @@ void swap(Uint8& a, Uint8& b) {
} }
void Mapa::preparaTombes() { void Mapa::preparaTombes() {
const Uint8 contingut = info::num_piramide == 6 ? CONTE_DIAMANT : CONTE_RES; const Uint8 contingut = info::ctx.num_piramide == 6 ? CONTE_DIAMANT : CONTE_RES;
int cx = info::num_piramide == 6 ? 270 : 0; int cx = info::ctx.num_piramide == 6 ? 270 : 0;
int cy = info::num_piramide == 6 ? 50 : 0; int cy = info::ctx.num_piramide == 6 ? 50 : 0;
for (int i = 0; i < 16; i++) { for (int i = 0; i < 16; i++) {
this->tombes[i].contingut = contingut; this->tombes[i].contingut = contingut;
@@ -171,7 +171,7 @@ void Mapa::preparaTombes() {
this->tombes[i].x = cx; this->tombes[i].x = cx;
this->tombes[i].y = cy; this->tombes[i].y = cy;
} }
if (info::num_piramide == 6) return; if (info::ctx.num_piramide == 6) return;
this->tombes[0].contingut = CONTE_FARAO; this->tombes[0].contingut = CONTE_FARAO;
this->tombes[1].contingut = CONTE_CLAU; this->tombes[1].contingut = CONTE_CLAU;
this->tombes[2].contingut = CONTE_PERGAMI; this->tombes[2].contingut = CONTE_PERGAMI;
@@ -241,7 +241,7 @@ void Mapa::comprovaCaixa(Uint8 num) {
break; break;
case CONTE_TRESOR: case CONTE_TRESOR:
this->tombes[num].x = 100; this->tombes[num].x = 100;
info::diners++; info::ctx.diners++;
break; break;
case CONTE_FARAO: case CONTE_FARAO:
this->tombes[num].x = 150; this->tombes[num].x = 150;
@@ -261,9 +261,9 @@ void Mapa::comprovaCaixa(Uint8 num) {
break; break;
case CONTE_DIAMANT: case CONTE_DIAMANT:
this->tombes[num].y = 70; this->tombes[num].y = 70;
info::diamants++; info::ctx.diamants++;
info::diners += VALOR_DIAMANT; info::ctx.diners += VALOR_DIAMANT;
if (info::diamants == 16) this->farao = this->clau = true; if (info::ctx.diamants == 16) this->farao = this->clau = true;
break; break;
} }

View File

@@ -27,9 +27,14 @@ struct Vertex {
class Mapa { class Mapa {
public: public:
Mapa(JD8_Surface gfx, Prota* sam); explicit Mapa(JD8_Surface gfx, Prota* sam);
~Mapa(void); ~Mapa(void);
Mapa(const Mapa&) = delete;
Mapa& operator=(const Mapa&) = delete;
Mapa(Mapa&&) = delete;
Mapa& operator=(Mapa&&) = delete;
void draw(); void draw();
void update(); void update();
bool novaMomia(); bool novaMomia();

View File

@@ -9,19 +9,19 @@ Marcador::~Marcador(void) {
} }
void Marcador::draw() { void Marcador::draw() {
if (info::num_piramide < 6) { if (info::ctx.num_piramide < 6) {
this->pintaNumero(55, 2, info::num_piramide); this->pintaNumero(55, 2, info::ctx.num_piramide);
this->pintaNumero(80, 2, info::num_habitacio); this->pintaNumero(80, 2, info::ctx.num_habitacio);
} }
this->pintaNumero(149, 2, info::diners / 100); this->pintaNumero(149, 2, info::ctx.diners / 100);
this->pintaNumero(156, 2, (info::diners % 100) / 10); this->pintaNumero(156, 2, (info::ctx.diners % 100) / 10);
this->pintaNumero(163, 2, info::diners % 10); this->pintaNumero(163, 2, info::ctx.diners % 10);
if (this->sam->pergami) JD8_BlitCK(190, 1, this->gfx, 209, 185, 15, 14, 255); if (this->sam->pergami) JD8_BlitCK(190, 1, this->gfx, 209, 185, 15, 14, 255);
JD8_BlitCK(271, 1, this->gfx, 0, 20, 15, info::vida * 3, 255); JD8_BlitCK(271, 1, this->gfx, 0, 20, 15, info::ctx.vida * 3, 255);
if (info::vida < 5) JD8_BlitCK(271, 1 + (info::vida * 3), this->gfx, 75, 20, 15, 15 - (info::vida * 3), 255); if (info::ctx.vida < 5) JD8_BlitCK(271, 1 + (info::ctx.vida * 3), this->gfx, 75, 20, 15, 15 - (info::ctx.vida * 3), 255);
} }
void Marcador::pintaNumero(Uint16 x, Uint16 y, Uint8 num) { void Marcador::pintaNumero(Uint16 x, Uint16 y, Uint8 num) {

View File

@@ -6,7 +6,7 @@
class Marcador { class Marcador {
public: public:
Marcador(JD8_Surface gfx, Prota* sam); explicit Marcador(JD8_Surface gfx, Prota* sam);
~Marcador(void); ~Marcador(void);
void draw(); void draw();

View File

@@ -1,127 +1,139 @@
#include "game/modulegame.hpp" #include "game/modulegame.hpp"
#include "core/jail/jail_audio.hpp" #include <algorithm>
#include "core/audio/audio.hpp"
#include "core/jail/jdraw8.hpp" #include "core/jail/jdraw8.hpp"
#include "core/jail/jfile.hpp"
#include "core/jail/jgame.hpp" #include "core/jail/jgame.hpp"
#include "core/jail/jinput.hpp" #include "core/jail/jinput.hpp"
ModuleGame::ModuleGame() { ModuleGame::ModuleGame() {
this->gfx = JD8_LoadSurface(info::pepe_activat ? "frames2.gif" : "frames.gif"); this->gfx = JD8_LoadSurface(info::ctx.pepe_activat ? "gfx/frames2.gif" : "gfx/frames.gif");
JG_SetUpdateTicks(10); JG_SetUpdateTicks(10);
this->sam = new Prota(this->gfx); this->sam = std::make_unique<Prota>(this->gfx);
this->mapa = new Mapa(this->gfx, this->sam); this->mapa = std::make_unique<Mapa>(this->gfx, this->sam.get());
this->marcador = new Marcador(this->gfx, this->sam); this->marcador = std::make_unique<Marcador>(this->gfx, this->sam.get());
if (info::num_piramide == 2) { if (info::ctx.num_piramide == 2) {
this->bola = new Bola(this->gfx, this->sam); this->bola = std::make_unique<Bola>(this->gfx, this->sam.get());
} else {
this->bola = NULL;
} }
this->momies = NULL;
this->final = 0;
this->iniciarMomies(); this->iniciarMomies();
} }
ModuleGame::~ModuleGame(void) { ModuleGame::~ModuleGame() {
JD8_FadeOut();
if (this->bola != NULL) delete this->bola;
if (this->momies != NULL) {
this->momies->clear();
delete this->momies;
}
delete this->marcador;
delete this->mapa;
delete this->sam;
JD8_FreeSurface(this->gfx); JD8_FreeSurface(this->gfx);
} }
int ModuleGame::Go() { void ModuleGame::onEnter() {
// Primera Draw per omplir `screen` amb el contingut del gameplay
// abans que el fade-in arranque. Si no, les primeres iteracions del
// fade interpolarien cap a una paleta amb pantalla buida.
this->Draw(); this->Draw();
const char* music = info::num_piramide == 3 ? "00000008.ogg" : (info::num_piramide == 2 ? "00000007.ogg" : (info::num_piramide == 6 ? "00000002.ogg" : "00000006.ogg")); // Audio::playMusic ja és idempotent: si la pista actual coincideix amb la
const char* current_music = JA_GetMusicFilename(); // demanada, no fa res. Per això podem cridar-lo cada onEnter sense
if ((JA_GetMusicState() != JA_MUSIC_PLAYING) || !(strcmp(music, current_music) == 0)) { // desencadenar restarts indesitjats.
int size; const char* music_name = info::ctx.num_piramide == 3 ? "piramide_3.ogg"
char* buffer = file_getfilebuffer(music, size); : info::ctx.num_piramide == 2 ? "piramide_2.ogg"
JA_PlayMusic(JA_LoadMusic((Uint8*)buffer, size, music)); : info::ctx.num_piramide == 6 ? "secreta.ogg"
: "piramide_1_4_5.ogg";
Audio::get()->playMusic(music_name);
// Arranca el fade-in tick-based. El `PaletteFade` avança un pas (de
// 32) per cada tick; durant aquesta fase el gameplay no corre,
// només Draw+fade. Substituïx la crida bloquejant `JD8_FadeToPal`.
fade_.startFadeTo(JD8_LoadPalette(info::ctx.pepe_activat ? "gfx/frames2.gif" : "gfx/frames.gif"));
phase_ = Phase::FadingIn;
} }
JD8_FadeToPal(JD8_LoadPalette(info::pepe_activat ? "frames2.gif" : "frames.gif")); void ModuleGame::tick(int delta_ms) {
switch (phase_) {
case Phase::FadingIn:
// No redibuixem durant el fade: el `screen` ja va ser omplit
// per la Draw() d'onEnter. Només el JD8_Flip del caller muta
// pixel_data segons la paleta que avança pas a pas.
fade_.tick(delta_ms);
if (fade_.done()) phase_ = Phase::Playing;
break;
while (this->final == 0 && !JG_Quitting()) { case Phase::Playing:
this->Draw(); this->Draw();
this->Update(); this->Update();
if (this->final_ != 0) {
this->applyFinalTransitions();
fade_.startFadeOut();
phase_ = Phase::FadingOut;
}
break;
case Phase::FadingOut:
// No redibuixem: el `screen` té l'últim frame pintat per la
// fase Playing (just abans que Update() setegés `final_`).
// El vell `JD8_FadeOut` feia exactament això — flips amb
// paleta fading però sense tocar el buffer. Redibuixar ací
// mostraria l'estat post-Update del sprite (p.ex. el prota
// "tornant" davant la porta després d'haver eixit).
fade_.tick(delta_ms);
if (fade_.done()) phase_ = Phase::Done;
break;
case Phase::Done:
break;
}
} }
// JS_FadeOutMusic(); int ModuleGame::nextState() const {
if (JG_Quitting()) return -1;
if (this->final == 1) { if (info::ctx.num_habitacio == 1 ||
info::num_habitacio++; info::ctx.num_piramide == 100 ||
if (info::num_habitacio == 6) { info::ctx.num_piramide == 7) {
info::num_habitacio = 1;
info::num_piramide++;
}
if (info::num_piramide == 6 && info::num_habitacio == 2) info::num_piramide++;
} else if (this->final == 2) {
info::num_piramide = 100;
}
if (JG_Quitting()) {
return -1;
} else {
if (info::num_habitacio == 1 || info::num_piramide == 100 || info::num_piramide == 7) {
return 1; return 1;
} else { }
return 0; return 0;
} }
void ModuleGame::applyFinalTransitions() {
if (this->final_ == 1) {
info::ctx.num_habitacio++;
if (info::ctx.num_habitacio == 6) {
info::ctx.num_habitacio = 1;
info::ctx.num_piramide++;
}
if (info::ctx.num_piramide == 6 && info::ctx.num_habitacio == 2) info::ctx.num_piramide++;
} else if (this->final_ == 2) {
info::ctx.num_piramide = 100;
} }
} }
void ModuleGame::Draw() { void ModuleGame::Draw() {
// No crida JD8_Flip — el caller (mini-loop del fiber, o Director a
// Phase B.2) ho fa després de cada tick.
this->mapa->draw(); this->mapa->draw();
this->marcador->draw(); this->marcador->draw();
this->sam->draw(); this->sam->draw();
if (this->momies != NULL) this->momies->draw(); for (auto& m : this->momies) m->draw();
if (this->bola != NULL) this->bola->draw(); if (this->bola) this->bola->draw();
JD8_Flip();
} }
void ModuleGame::Update() { void ModuleGame::Update() {
if (JG_ShouldUpdate()) { if (JG_ShouldUpdate()) {
JI_Update(); JI_Update();
this->final = this->sam->update(); this->final_ = this->sam->update();
if (this->momies != NULL && this->momies->update()) { const auto erased = std::erase_if(this->momies, [](auto& m) { return m->update(); });
Momia* seguent = this->momies->next; info::ctx.momies -= static_cast<int>(erased);
delete this->momies; if (this->bola) this->bola->update();
this->momies = seguent;
info::momies--;
}
if (this->bola != NULL) this->bola->update();
this->mapa->update(); this->mapa->update();
if (this->mapa->novaMomia()) { if (this->mapa->novaMomia()) {
if (this->momies != NULL) { this->momies.emplace_back(std::make_unique<Momia>(this->gfx, true, 0, 0, this->sam.get()));
this->momies->insertar(new Momia(this->gfx, true, 0, 0, this->sam)); info::ctx.momies++;
info::momies++;
} else {
this->momies = new Momia(this->gfx, true, 0, 0, this->sam);
info::momies++;
}
} }
if (JI_CheatActivated("reviu")) info::vida = 5; if (JI_CheatActivated("reviu")) info::ctx.vida = 5;
if (JI_CheatActivated("alone")) { if (JI_CheatActivated("alone")) {
if (this->momies != NULL) { this->momies.clear();
this->momies->clear(); info::ctx.momies = 0;
delete this->momies;
this->momies = NULL;
info::momies = 0;
}
} }
if (JI_CheatActivated("obert")) { if (JI_CheatActivated("obert")) {
for (int i = 0; i < 16; i++) { for (int i = 0; i < 16; i++) {
@@ -140,22 +152,18 @@ void ModuleGame::Update() {
} }
void ModuleGame::iniciarMomies() { void ModuleGame::iniciarMomies() {
if (info::num_habitacio == 1) { if (info::ctx.num_habitacio == 1) {
info::momies = 1; info::ctx.momies = 1;
} else { } else {
info::momies++; info::ctx.momies++;
} }
if (info::num_piramide == 6) info::momies = 8; if (info::ctx.num_piramide == 6) info::ctx.momies = 8;
int x = 20; int x = 20;
int y = 170; int y = 170;
bool dimonis = info::num_piramide == 6; bool dimonis = info::ctx.num_piramide == 6;
for (int i = 0; i < info::momies; i++) { for (int i = 0; i < info::ctx.momies; i++) {
if (this->momies == NULL) { this->momies.emplace_back(std::make_unique<Momia>(this->gfx, dimonis, x, y, this->sam.get()));
this->momies = new Momia(this->gfx, dimonis, x, y, this->sam);
} else {
this->momies->insertar(new Momia(this->gfx, dimonis, x, y, this->sam));
}
x += 65; x += 65;
if (x == 345) { if (x == 345) {
x = 20; x = 20;

Some files were not shown because too many files have changed in this diff Show More