Compare commits
90 Commits
v1.1
..
9c0aece0dd
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c0aece0dd | |||
| 29a919be3a | |||
| d89141e014 | |||
| b65a615be2 | |||
| da56a81bc3 | |||
| a95b4bd1b6 | |||
| b0c95111a2 | |||
| 96763847fb | |||
| dcb004b5a7 | |||
| 70aa58ec46 | |||
| e1bc1b597f | |||
| b984e6041e | |||
| ae359f4a1e | |||
| ae89b252e2 | |||
| 35cdd88cbb | |||
| 4cac807ce2 | |||
| bbcc10da81 | |||
| 9d30dd538c | |||
| 1e00f5c3a4 | |||
| 7789c1c217 | |||
| ec3cb78f6b | |||
| f37308a5f0 | |||
| 1ce0d9c56c | |||
| 08f587ffe4 | |||
| bf7be3a7f1 | |||
| a48fe51f73 | |||
| 0b82be193f | |||
| 8676c0e773 | |||
| b7a551c158 | |||
| 358e91ea30 | |||
| 1aa0e96a91 | |||
| 6ac16ebfeb | |||
| 5dcda36553 | |||
| 41d429fc10 | |||
| 4435bc4942 | |||
| 4a4485c6f8 | |||
| d09bb1cf6b | |||
| b1f9e57f36 | |||
| f7875baa2d | |||
| c6e37af7d1 | |||
| 5e57034a38 | |||
| 2a8fbbb095 | |||
| 53e93ef697 | |||
| e7aa2463b4 | |||
| 27f8b0ae36 | |||
| 2e1a82ff40 | |||
| 94aa69cffe | |||
| 7409c799c3 | |||
| 417699d276 | |||
| 9d86137203 | |||
| 52369be7ae | |||
| 1c11a3057b | |||
| e8b0b12f98 | |||
| 16a3f5b470 | |||
| 5cda8fc3f9 | |||
| 5956d874c3 | |||
| e0f9b60f22 | |||
| d3bdd9b783 | |||
| a36662ac6e | |||
| 52431adb0e | |||
| a3fc1119ae | |||
| 6394e9afab | |||
| fe41919e1e | |||
| 0cd09f6d28 | |||
| 083a57dab5 | |||
| 4244bcaea3 | |||
| b2d5f5af61 | |||
| 7f26b8dbd0 | |||
| 550e3e0e12 | |||
| 96a3cf9ebc | |||
| 4e18f83ec5 | |||
| f9346add79 | |||
| b3ff620c81 | |||
| d343e719ca | |||
| e18b7321eb | |||
| 6125277d70 | |||
| 6063b1c606 | |||
| 829d7431c1 | |||
| 605c273173 | |||
| ad38fc09cf | |||
| 8720e775a0 | |||
| 2cb38ffb49 | |||
| d86cb21efa | |||
| 4436f7f569 | |||
| 1507a1c740 | |||
| 801a8ad1bd | |||
| 80fa7b46e7 | |||
| 7f85b50c63 | |||
| 2c833d086e | |||
| 91fe0625d3 |
@@ -0,0 +1,107 @@
|
||||
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
|
||||
|
||||
WarningsAsErrors: '*'
|
||||
# Headers nostres (excloem source/external/ que conté dependències de tercers no editables)
|
||||
HeaderFilterRegex: 'source/(core|game|utils)/'
|
||||
FormatStyle: file
|
||||
|
||||
CheckOptions:
|
||||
# bugprone-empty-catch: aceptar catches vacíos marcados con @INTENTIONAL en un comentario
|
||||
- { key: bugprone-empty-catch.IgnoreCatchWithKeywords, value: '@INTENTIONAL' }
|
||||
|
||||
# =====================================================================
|
||||
# CONSTANTES → UPPER_CASE (compile-time y runtime, en cualquier scope)
|
||||
# =====================================================================
|
||||
# Todo lo que sea const o constexpr se identifica visualmente en UPPER_CASE,
|
||||
# sin importar si es global, local, miembro o static.
|
||||
|
||||
# constexpr en cualquier scope (globales y locales)
|
||||
- { key: readability-identifier-naming.ConstexprVariableCase, value: UPPER_CASE }
|
||||
|
||||
# Constantes globales (const no-constexpr)
|
||||
- { key: readability-identifier-naming.GlobalConstantCase, value: UPPER_CASE }
|
||||
|
||||
# Constantes locales (const en función)
|
||||
- { key: readability-identifier-naming.LocalConstantCase, value: UPPER_CASE }
|
||||
|
||||
# Static const a nivel de archivo/namespace
|
||||
- { key: readability-identifier-naming.StaticConstantCase, value: UPPER_CASE }
|
||||
|
||||
# Miembros static const/constexpr de clase (p.ej. static constexpr int MAX = 100;)
|
||||
- { key: readability-identifier-naming.ClassConstantCase, value: UPPER_CASE }
|
||||
|
||||
# Miembros const no-static de clase (p.ej. const int limit;)
|
||||
- { key: readability-identifier-naming.ConstantMemberCase, value: UPPER_CASE }
|
||||
|
||||
# Valores de enums
|
||||
- { key: readability-identifier-naming.EnumConstantCase, value: UPPER_CASE }
|
||||
|
||||
# NOTA: Los parámetros const NO se tratan como constantes aquí.
|
||||
# Un parámetro sigue siendo un parámetro aunque sea const → hereda ParameterCase.
|
||||
|
||||
# =====================================================================
|
||||
# VARIABLES NO-CONST
|
||||
# =====================================================================
|
||||
|
||||
# Variables locales
|
||||
- { key: readability-identifier-naming.VariableCase, value: lower_case }
|
||||
- { key: readability-identifier-naming.LocalVariableCase, value: lower_case }
|
||||
|
||||
# Parámetros de función
|
||||
- { key: readability-identifier-naming.ParameterCase, value: lower_case }
|
||||
|
||||
# Variables estáticas no-const (static locales, static file-scope,
|
||||
# y static members no-const de clase como el instance_ de un Singleton).
|
||||
# Sufijo _ para marcar que tienen storage estático.
|
||||
- { key: readability-identifier-naming.StaticVariableCase, value: lower_case }
|
||||
- { key: readability-identifier-naming.StaticVariableSuffix, value: _ }
|
||||
|
||||
# =====================================================================
|
||||
# MIEMBROS DE CLASE NO-CONST
|
||||
# =====================================================================
|
||||
# Privados: snake_case con sufijo _
|
||||
- { key: readability-identifier-naming.PrivateMemberCase, value: lower_case }
|
||||
- { key: readability-identifier-naming.PrivateMemberSuffix, value: _ }
|
||||
|
||||
# Protegidos: snake_case con sufijo _
|
||||
- { key: readability-identifier-naming.ProtectedMemberCase, value: lower_case }
|
||||
- { key: readability-identifier-naming.ProtectedMemberSuffix, value: _ }
|
||||
|
||||
# Públicos: snake_case sin sufijo
|
||||
- { key: readability-identifier-naming.PublicMemberCase, value: lower_case }
|
||||
|
||||
# =====================================================================
|
||||
# TIPOS
|
||||
# =====================================================================
|
||||
- { key: readability-identifier-naming.ClassCase, value: CamelCase }
|
||||
- { key: readability-identifier-naming.StructCase, value: CamelCase }
|
||||
- { key: readability-identifier-naming.EnumCase, value: CamelCase }
|
||||
- { key: readability-identifier-naming.UnionCase, value: CamelCase }
|
||||
- { key: readability-identifier-naming.TypeAliasCase, value: CamelCase }
|
||||
- { key: readability-identifier-naming.TypedefCase, value: CamelCase }
|
||||
- { key: readability-identifier-naming.TemplateParameterCase, value: CamelCase }
|
||||
|
||||
# Namespaces
|
||||
- { key: readability-identifier-naming.NamespaceCase, value: CamelCase }
|
||||
|
||||
# =====================================================================
|
||||
# FUNCIONES Y MÉTODOS (incluyendo constexpr)
|
||||
# =====================================================================
|
||||
# Un método/función constexpr es un invocable, no una constante → camelBack.
|
||||
- { key: readability-identifier-naming.FunctionCase, value: camelBack }
|
||||
- { key: readability-identifier-naming.ConstexprFunctionCase, value: camelBack }
|
||||
- { 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 }
|
||||
- { key: readability-identifier-naming.ConstexprMethodCase, value: camelBack }
|
||||
@@ -0,0 +1 @@
|
||||
{"sessionId":"7b0c9c32-3dd4-48a3-ba06-c2303dc08243","pid":123890,"acquiredAt":1776510185734}
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pre-commit hook: aplica clang-format als fitxers C++ staged abans del commit.
|
||||
# - Només toca fitxers staged dins source/ (exclou source/external/).
|
||||
# - Avorta el commit si hi ha canvis NO staged en aquests fitxers (per no incloure'ls sense voler).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if ! command -v clang-format >/dev/null 2>&1; then
|
||||
echo "pre-commit: clang-format no trobat — saltant format check" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mapfile -t STAGED < <(git diff --cached --name-only --diff-filter=ACMR \
|
||||
| grep -E '^source/.*\.(cpp|hpp|h)$' \
|
||||
| grep -vE '^source/external/' || true)
|
||||
|
||||
if [ ${#STAGED[@]} -eq 0 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
UNSTAGED_DIRTY=()
|
||||
for f in "${STAGED[@]}"; do
|
||||
if ! git diff --quiet -- "$f"; then
|
||||
UNSTAGED_DIRTY+=("$f")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#UNSTAGED_DIRTY[@]} -gt 0 ]; then
|
||||
echo "pre-commit: aquests fitxers tenen canvis NO staged i estan al commit." >&2
|
||||
echo " Fes 'git add' o 'git stash' abans de continuar:" >&2
|
||||
printf ' %s\n' "${UNSTAGED_DIRTY[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
clang-format -i "${STAGED[@]}"
|
||||
git add -- "${STAGED[@]}"
|
||||
|
||||
# --- clang-tidy només sobre els fitxers staged ---
|
||||
if ! command -v clang-tidy >/dev/null 2>&1; then
|
||||
echo "pre-commit: clang-tidy no trobat — saltant tidy" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
BUILD_DIR="$REPO_ROOT/build"
|
||||
|
||||
if [ ! -f "$BUILD_DIR/compile_commands.json" ]; then
|
||||
echo "pre-commit: generant compile_commands.json (build dir buit)..." >&2
|
||||
cmake -S "$REPO_ROOT" -B "$BUILD_DIR" >/dev/null
|
||||
fi
|
||||
|
||||
echo "pre-commit: clang-tidy sobre ${#STAGED[@]} fitxer(s)..." >&2
|
||||
if ! clang-tidy -p "$BUILD_DIR" --quiet "${STAGED[@]}"; then
|
||||
echo "pre-commit: clang-tidy ha trobat errors — commit avortat" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- cppcheck només sobre els .cpp staged ---
|
||||
if ! command -v cppcheck >/dev/null 2>&1; then
|
||||
echo "pre-commit: cppcheck no trobat — saltant cppcheck" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
CPP_STAGED=()
|
||||
for f in "${STAGED[@]}"; do
|
||||
[[ "$f" == *.cpp ]] && CPP_STAGED+=("$f")
|
||||
done
|
||||
|
||||
if [ ${#CPP_STAGED[@]} -eq 0 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "pre-commit: cppcheck sobre ${#CPP_STAGED[@]} fitxer(s)..." >&2
|
||||
if ! cppcheck \
|
||||
--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/*' \
|
||||
--suppress=normalCheckLevelMaxBranches \
|
||||
-D_DEBUG \
|
||||
-DLINUX_BUILD \
|
||||
--quiet \
|
||||
--error-exitcode=1 \
|
||||
-I "$REPO_ROOT/source" \
|
||||
"${CPP_STAGED[@]}"; then
|
||||
echo "pre-commit: cppcheck ha trobat errors — commit avortat" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,7 +1,57 @@
|
||||
aee
|
||||
.DS_Store
|
||||
trick.ini
|
||||
.vscode/
|
||||
data.jrf
|
||||
# --- Build outputs ---
|
||||
build/
|
||||
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*
|
||||
|
||||
@@ -1,6 +1,88 @@
|
||||
# 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 2–5 (`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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -24,26 +24,74 @@ The executable is output to the project root. The `data/` folder must be in the
|
||||
|
||||
## 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 (0–7) i la migració `scenes::` (Steps 0–10) 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
|
||||
|
||||
| Path | Owner | Rule |
|
||||
|------|-------|------|
|
||||
| `source/core/jail/` | Original engine | **Do not modify** gameplay behavior |
|
||||
| `source/game/*.cpp/hpp` (except options/defines/defaults) | Original game | **Do not modify** |
|
||||
| `source/core/jail/` | Legacy engine, modernization target | Free to modify with care — preserve external behavior |
|
||||
| `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/input/` | New input layer | Free to modify |
|
||||
| `source/utils/` | New utilities | Free to modify |
|
||||
| `source/game/options,defines,defaults` | New config system | Free to modify |
|
||||
| `data/*.gif, *.ogg` | Original assets | **Do not modify** |
|
||||
| `data/fonts/, data/ui/, data/shaders/` | New assets | Free to modify |
|
||||
| `data/gfx/, data/music/` | Original assets | **Do not modify** — assets remain untouchable |
|
||||
| `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()`
|
||||
- **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/`)
|
||||
|
||||
- **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/`)
|
||||
|
||||
@@ -65,9 +113,10 @@ Flat C-style APIs (no classes), prefixed by subsystem. **Do not touch gameplay l
|
||||
|
||||
### 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
|
||||
- **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`
|
||||
|
||||
### 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:
|
||||
|
||||
- **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`
|
||||
- **options.hpp/cpp** — `Options` namespace with inline globals and YAML load/save. Structs: `KeysGUI`, `KeysGame`, `Video`, `RenderInfo`, `Audio`, `Window`, `Game`, `PostFXPreset`, `CrtPiPreset`
|
||||
- **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: `KeysGame`, `Video`, `RenderInfo`, `Audio`, `Window`, `Game`, `PostFXPreset`, `CrtPiPreset`
|
||||
|
||||
### Utilities (`source/utils/`)
|
||||
|
||||
@@ -99,40 +148,58 @@ Follows the pattern from `jaildoctors_dilemma`, persists to YAML:
|
||||
| F6 | Toggle supersampling |
|
||||
| F7 | Cycle shader type (PostFX ↔ CRT-Pi) |
|
||||
| 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) |
|
||||
| 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 |
|
||||
| ESC | Double-press to quit (with overlay notification) / close menu if open |
|
||||
| Backspace | Go up one menu level / close menu if at root |
|
||||
| ↑↓←→ / 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())
|
||||
──────────────────── ────────────────────────────────────
|
||||
loop at ~60 FPS { loop {
|
||||
SDL_PollEvent() ... game logic ...
|
||||
GlobalInputs, Mouse JD8_Flip():
|
||||
if new_frame_available: palette→ARGB in pixel_data
|
||||
copy to game_frame publishFrame(pixel_data) ⏸
|
||||
signal → ────────────────────→ (blocks until Director consumes)
|
||||
copy game_frame → present_buffer ←──── signal_consumed
|
||||
Overlay::render(present_buffer) continue game loop
|
||||
Screen::present(present_buffer) }
|
||||
SDL_Delay to hit 60fps
|
||||
SDL_AppIterate → Director::iterate() {
|
||||
if (quit_requested_) { scene.reset(); return false; }
|
||||
if (!context_initialized_) initGameContext();
|
||||
|
||||
Gamepad/KeyRemap/GlobalInputs/Mouse::update
|
||||
JA_Update() ← audio pump
|
||||
|
||||
if (!paused_) {
|
||||
if (scene && (scene->done() || JG_Quitting()))
|
||||
game_state_ = scene->nextState(); scene.reset();
|
||||
if (!scene) {
|
||||
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:**
|
||||
- Director is NON-BLOCKING: if no new frame, re-presents the last one with fresh overlay
|
||||
- Double buffer: `game_frame` (untouched copy from game) + `presentation_buffer` (regenerated with overlay each frame)
|
||||
- Game thread pauses at `JD8_Flip()` waiting for Director — natural sync point
|
||||
- SDL events processed ONLY on main thread (SDL requirement)
|
||||
- `JI_Update()` no longer polls events — reads Director's state
|
||||
- `Director` posseeix `current_scene_`, `game_state_`, `last_tick_ms_`, `context_initialized_` com a members — abans vivien al stack del fiber.
|
||||
- `JD8_Flip()` només converteix paleta + `screen` a ARGB (`pixel_data`). Ja **no fa yield** — tot corre lineal.
|
||||
- `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.
|
||||
- Pausa (F11) simplement salta el bloc de tick; overlay i present continuen, es re-presenta l'últim frame congelat.
|
||||
- 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)
|
||||
|
||||
@@ -158,10 +225,40 @@ JD8_Flip produces ABGR byte order: `0xFF000000 + R + (G<<8) + (B<<16)`. SDL text
|
||||
|
||||
| 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/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/`)
|
||||
|
||||
- `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/`)
|
||||
|
||||
- `*.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)
|
||||
- `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
|
||||
- `ui/` — Reserved for future UI graphics
|
||||
|
||||
### 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
|
||||
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.
|
||||
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 1–10) + `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
|
||||
|
||||
- **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).
|
||||
- **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.
|
||||
@@ -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.
|
||||
- **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.
|
||||
- **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)
|
||||
|
||||
@@ -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`.
|
||||
|
||||
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).
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
cmake_minimum_required(VERSION 3.10)
|
||||
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++
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
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
|
||||
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 ---
|
||||
set(APP_SOURCES
|
||||
# Core - Motor original "Jail" (no tocar gameplay)
|
||||
source/core/jail/jail_audio.cpp
|
||||
source/core/jail/jdraw8.cpp
|
||||
source/core/jail/jfile.cpp
|
||||
source/core/jail/jgame.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)
|
||||
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)
|
||||
source/core/rendering/menu.cpp
|
||||
source/core/rendering/overlay.cpp
|
||||
@@ -34,12 +71,32 @@ set(APP_SOURCES
|
||||
# Core - Input (nova capa)
|
||||
source/core/input/gamepad.cpp
|
||||
source/core/input/global_inputs.cpp
|
||||
source/core/input/key_config.cpp
|
||||
source/core/input/key_remap.cpp
|
||||
source/core/input/mouse.cpp
|
||||
|
||||
# Core - System (nova capa)
|
||||
source/core/system/director.cpp
|
||||
|
||||
# Scenes (cinemàtiques i menús reescrits)
|
||||
source/game/scenes/timeline.cpp
|
||||
source/game/scenes/sprite_mover.cpp
|
||||
source/game/scenes/frame_animator.cpp
|
||||
source/game/scenes/palette_fade.cpp
|
||||
source/game/scenes/surface_handle.cpp
|
||||
source/game/scenes/scene_registry.cpp
|
||||
source/game/scenes/scene_utils.cpp
|
||||
source/game/scenes/boot_loader_scene.cpp
|
||||
source/game/scenes/mort_scene.cpp
|
||||
source/game/scenes/banner_scene.cpp
|
||||
source/game/scenes/menu_scene.cpp
|
||||
source/game/scenes/intro_new_logo_scene.cpp
|
||||
source/game/scenes/intro_scene.cpp
|
||||
source/game/scenes/intro_sprites_scene.cpp
|
||||
source/game/scenes/slides_scene.cpp
|
||||
source/game/scenes/credits_scene.cpp
|
||||
source/game/scenes/secreta_scene.cpp
|
||||
|
||||
# Game
|
||||
source/game/options.cpp
|
||||
source/game/bola.cpp
|
||||
@@ -48,7 +105,6 @@ set(APP_SOURCES
|
||||
source/game/mapa.cpp
|
||||
source/game/marcador.cpp
|
||||
source/game/modulegame.cpp
|
||||
source/game/modulesequence.cpp
|
||||
source/game/momia.cpp
|
||||
source/game/prota.cpp
|
||||
source/game/sprite.cpp
|
||||
@@ -63,8 +119,22 @@ set(APP_SOURCES
|
||||
|
||||
# Configuración de SDL3
|
||||
# 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.
|
||||
if(APPLE AND MACOS_BUNDLE)
|
||||
# En emscripten compilamos SDL3 desde source con FetchContent (no hi ha paquet de sistema).
|
||||
# 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")
|
||||
message(STATUS "SDL3: usando xcframework (${SDL3_XCFRAMEWORK_SLICE})")
|
||||
else()
|
||||
@@ -72,25 +142,23 @@ else()
|
||||
message(STATUS "SDL3 encontrado: ${SDL3_INCLUDE_DIRS}")
|
||||
endif()
|
||||
|
||||
# --- COMPILACIÓ SHADERS SPIR-V (Linux/Windows — macOS usa Metal) ---
|
||||
if(NOT APPLE)
|
||||
# --- COMPILACIÓ SHADERS SPIR-V (Linux/Windows — macOS usa Metal, Emscripten no suporta SDL3 GPU) ---
|
||||
if(NOT APPLE AND NOT EMSCRIPTEN)
|
||||
find_program(GLSLC_EXE NAMES glslc)
|
||||
|
||||
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
|
||||
"${HEADERS_DIR}/postfx_vert_spv.h"
|
||||
"${HEADERS_DIR}/postfx_frag_spv.h"
|
||||
"${HEADERS_DIR}/upscale_frag_spv.h"
|
||||
"${HEADERS_DIR}/downscale_frag_spv.h"
|
||||
"${HEADERS_DIR}/crtpi_frag_spv.h"
|
||||
)
|
||||
set(ALL_SHADER_SOURCES
|
||||
"${SHADERS_DIR}/postfx.vert"
|
||||
"${SHADERS_DIR}/postfx.frag"
|
||||
"${SHADERS_DIR}/upscale.frag"
|
||||
"${SHADERS_DIR}/downscale.frag"
|
||||
"${SHADERS_DIR}/crtpi_frag.glsl"
|
||||
)
|
||||
|
||||
@@ -120,21 +188,38 @@ if(NOT APPLE)
|
||||
endforeach()
|
||||
message(STATUS "glslc no trobat — usant headers SPIR-V precompilats")
|
||||
endif()
|
||||
elseif(EMSCRIPTEN)
|
||||
message(STATUS "Emscripten: shaders SPIR-V omesos (SDL3 GPU no suportat a WebGL2)")
|
||||
else()
|
||||
message(STATUS "macOS: shaders SPIR-V omesos (usa Metal)")
|
||||
endif()
|
||||
|
||||
# --- EJECUTABLE ---
|
||||
add_executable(${PROJECT_NAME} ${APP_SOURCES})
|
||||
# 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})
|
||||
endif()
|
||||
|
||||
# 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)
|
||||
endif()
|
||||
|
||||
# --- DIRECTORIOS DE INCLUSIÓN ---
|
||||
target_include_directories(${PROJECT_NAME} PUBLIC
|
||||
"${CMAKE_SOURCE_DIR}/source"
|
||||
"${CMAKE_BINARY_DIR}"
|
||||
)
|
||||
|
||||
# Capçaleres de tercers a source/external/ — tractades com a sistema per
|
||||
# silenciar warnings (gif.h, etc.) que no controlem.
|
||||
target_include_directories(${PROJECT_NAME} SYSTEM PUBLIC
|
||||
"${CMAKE_SOURCE_DIR}/source/external"
|
||||
)
|
||||
|
||||
# Enlazar SDL3
|
||||
@@ -153,21 +238,93 @@ else()
|
||||
endif()
|
||||
|
||||
# --- FLAGS DE COMPILACIÓN ---
|
||||
target_compile_options(${PROJECT_NAME} PRIVATE -Wall)
|
||||
target_compile_options(${PROJECT_NAME} PRIVATE -Wall -Wextra -Wpedantic)
|
||||
target_compile_options(${PROJECT_NAME} PRIVATE $<$<CONFIG:RELEASE>:-Os -ffunction-sections -fdata-sections>)
|
||||
|
||||
# --- CONFIGURACIÓN POR PLATAFORMA ---
|
||||
if(WIN32)
|
||||
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()
|
||||
|
||||
# Ejecutable en la raíz del proyecto
|
||||
set_target_properties(${PROJECT_NAME} PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR})
|
||||
# --- 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 -Wextra -Wpedantic)
|
||||
|
||||
# --- CLANG-FORMAT TARGETS ---
|
||||
# --- 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_BINARY_DIR}/resources.pack")
|
||||
|
||||
add_custom_command(
|
||||
OUTPUT ${RESOURCE_PACK}
|
||||
COMMAND $<TARGET_FILE:pack_resources>
|
||||
"${CMAKE_SOURCE_DIR}/data"
|
||||
"${RESOURCE_PACK}"
|
||||
DEPENDS pack_resources ${DATA_FILES}
|
||||
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
|
||||
COMMENT "Empaquetant data/ → resources.pack"
|
||||
VERBATIM
|
||||
)
|
||||
|
||||
add_custom_target(resource_pack ALL DEPENDS ${RESOURCE_PACK})
|
||||
add_dependencies(${PROJECT_NAME} resource_pack)
|
||||
|
||||
# --- CÒPIA DE gamecontrollerdb.txt AL COSTAT DEL BINARI ---
|
||||
# SDL_AddGamepadMappingsFromFile només llegeix del filesystem real (no del
|
||||
# pack), així que el fitxer ha de viure al directori del binari. Es copia
|
||||
# només si existeix per no fallar la build d'algú que encara no ha fet
|
||||
# `make controllerdb`.
|
||||
if(EXISTS "${CMAKE_SOURCE_DIR}/gamecontrollerdb.txt")
|
||||
set(CONTROLLER_DB "${CMAKE_BINARY_DIR}/gamecontrollerdb.txt")
|
||||
add_custom_command(
|
||||
OUTPUT ${CONTROLLER_DB}
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||
"${CMAKE_SOURCE_DIR}/gamecontrollerdb.txt"
|
||||
"${CONTROLLER_DB}"
|
||||
DEPENDS "${CMAKE_SOURCE_DIR}/gamecontrollerdb.txt"
|
||||
COMMENT "Copiant gamecontrollerdb.txt → build/"
|
||||
VERBATIM
|
||||
)
|
||||
add_custom_target(controller_db ALL DEPENDS ${CONTROLLER_DB})
|
||||
add_dependencies(${PROJECT_NAME} controller_db)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# --- STATIC ANALYSIS TARGETS ---
|
||||
find_program(CLANG_TIDY_EXE NAMES clang-tidy)
|
||||
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
|
||||
"${CMAKE_SOURCE_DIR}/source/*.cpp"
|
||||
"${CMAKE_SOURCE_DIR}/source/*.hpp"
|
||||
@@ -175,6 +332,35 @@ file(GLOB_RECURSE ALL_SOURCE_FILES
|
||||
)
|
||||
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)
|
||||
add_custom_target(format
|
||||
COMMAND ${CLANG_FORMAT_EXE}
|
||||
@@ -195,3 +381,25 @@ if(CLANG_FORMAT_EXE)
|
||||
else()
|
||||
message(STATUS "clang-format no encontrado - targets 'format' y 'format-check' no disponibles")
|
||||
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()
|
||||
|
||||
@@ -2,13 +2,25 @@
|
||||
# DIRECTORIES
|
||||
# ==============================================================================
|
||||
DIR_ROOT := $(dir $(abspath $(MAKEFILE_LIST)))
|
||||
DIR_BIN := $(addsuffix /, $(DIR_ROOT))
|
||||
BUILDDIR := build
|
||||
|
||||
# ==============================================================================
|
||||
# TOOLS
|
||||
# ==============================================================================
|
||||
SHADER_CMAKE := $(DIR_ROOT)tools/shaders/compile_spirv.cmake
|
||||
SHADERS_DIR := $(DIR_ROOT)data/shaders
|
||||
HEADERS_DIR := $(DIR_ROOT)source/core/rendering/sdl3gpu/spv
|
||||
ifeq ($(OS),Windows_NT)
|
||||
GLSLC := $(shell where glslc 2>NUL)
|
||||
else
|
||||
GLSLC := $(shell command -v glslc 2>/dev/null)
|
||||
endif
|
||||
|
||||
# ==============================================================================
|
||||
# TARGET NAMES
|
||||
# ==============================================================================
|
||||
TARGET_NAME := aee
|
||||
TARGET_FILE := $(DIR_BIN)$(TARGET_NAME)
|
||||
TARGET_FILE := $(BUILDDIR)/$(TARGET_NAME)
|
||||
APP_NAME := Aventures en Egipte
|
||||
DIST_DIR := dist
|
||||
RELEASE_FOLDER := dist/_tmp
|
||||
@@ -18,9 +30,23 @@ RELEASE_FILE := $(RELEASE_FOLDER)/$(TARGET_NAME)
|
||||
# VERSION (extracted from defines.hpp)
|
||||
# ==============================================================================
|
||||
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
|
||||
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
|
||||
|
||||
# ==============================================================================
|
||||
@@ -35,11 +61,15 @@ endif
|
||||
# WINDOWS-SPECIFIC VARIABLES
|
||||
# ==============================================================================
|
||||
ifeq ($(OS),Windows_NT)
|
||||
WIN_TARGET_FILE := $(DIR_BIN)$(APP_NAME)
|
||||
WIN_TARGET_FILE := $(BUILDDIR)/$(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
|
||||
WIN_TARGET_FILE := $(TARGET_FILE)
|
||||
WIN_RELEASE_FILE := $(RELEASE_FILE)
|
||||
WIN_RELEASE_FILE_PS := $(WIN_RELEASE_FILE)
|
||||
endif
|
||||
|
||||
# ==============================================================================
|
||||
@@ -65,40 +95,95 @@ else
|
||||
UNAME_S := $(shell uname -s)
|
||||
endif
|
||||
|
||||
# ==============================================================================
|
||||
# CMAKE GENERATOR (usa Ninja si está disponible; si no, MinGW Makefiles en
|
||||
# Windows / generador por defecto en Linux/macOS). Ninja paraleliza mejor.
|
||||
# ==============================================================================
|
||||
ifeq ($(OS),Windows_NT)
|
||||
# Dins MSYS2/Git Bash/MinGW, $(shell ...) usa sh.exe i "NUL" NO és
|
||||
# dispositiu — un redirect "2>NUL" crearia un fitxer literal anomenat
|
||||
# NUL al cwd. Detectem MSYSTEM per usar /dev/null en aquests entorns.
|
||||
ifneq ($(MSYSTEM),)
|
||||
NULDEV := /dev/null
|
||||
else
|
||||
NULDEV := NUL
|
||||
endif
|
||||
HAS_NINJA := $(shell ninja --version 2>$(NULDEV))
|
||||
ifneq ($(HAS_NINJA),)
|
||||
CMAKE_GEN := -G "Ninja"
|
||||
else
|
||||
CMAKE_GEN := -G "MinGW Makefiles"
|
||||
endif
|
||||
else
|
||||
HAS_NINJA := $(shell ninja --version 2>/dev/null)
|
||||
ifneq ($(HAS_NINJA),)
|
||||
CMAKE_GEN := -G "Ninja"
|
||||
else
|
||||
CMAKE_GEN :=
|
||||
endif
|
||||
endif
|
||||
|
||||
# ==============================================================================
|
||||
# COMPILACIÓN CON CMAKE
|
||||
# ==============================================================================
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
run: all
|
||||
@./$(TARGET_FILE)
|
||||
|
||||
run-debug: debug
|
||||
@./$(TARGET_FILE)
|
||||
|
||||
clean:
|
||||
@rm -rf $(BUILDDIR)
|
||||
|
||||
rebuild: clean all
|
||||
|
||||
# ==============================================================================
|
||||
# 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 build/resources.pack
|
||||
|
||||
# ==============================================================================
|
||||
# RELEASE AUTOMÁTICO (detecta SO)
|
||||
# ==============================================================================
|
||||
release:
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@"$(MAKE)" _windows_release
|
||||
@"$(MAKE)" _windows-release
|
||||
else
|
||||
ifeq ($(UNAME_S),Darwin)
|
||||
@$(MAKE) _macos_release
|
||||
@$(MAKE) _macos-release
|
||||
else
|
||||
@$(MAKE) _linux_release
|
||||
@$(MAKE) _linux-release
|
||||
endif
|
||||
endif
|
||||
|
||||
# ==============================================================================
|
||||
# COMPILACIÓN PARA WINDOWS (RELEASE)
|
||||
# ==============================================================================
|
||||
_windows_release:
|
||||
_windows-release: pack
|
||||
@echo off
|
||||
@echo Creando release para Windows - Version: $(VERSION)
|
||||
|
||||
# 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
|
||||
|
||||
# Crea carpeta de distribución y carpeta temporal 'RELEASE_FOLDER'
|
||||
@@ -106,13 +191,13 @@ _windows_release:
|
||||
@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}"
|
||||
|
||||
# Copia ficheros
|
||||
@powershell -Command "Copy-Item -Path 'data' -Destination '$(RELEASE_FOLDER)' -Recurse"
|
||||
# Copia ficheros (resources.pack substitueix la carpeta data/)
|
||||
@powershell -Command "Copy-Item 'build/resources.pack' -Destination '$(RELEASE_FOLDER)'"
|
||||
@powershell -Command "Copy-Item 'LICENSE' -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 'release\windows\dll\*.dll' -Destination '$(RELEASE_FOLDER)'"
|
||||
@powershell -Command "Copy-Item -Path '$(TARGET_FILE)' -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
|
||||
|
||||
# Crea el fichero .zip
|
||||
@@ -126,15 +211,31 @@ _windows_release:
|
||||
# ==============================================================================
|
||||
# COMPILACIÓN PARA MACOS (RELEASE)
|
||||
# ==============================================================================
|
||||
_macos_release:
|
||||
_macos-release: pack
|
||||
@echo "Creando release para macOS - Version: $(VERSION)"
|
||||
|
||||
# Verificar e instalar create-dmg si es necesario
|
||||
@which create-dmg > /dev/null || (echo "Instalando create-dmg..." && brew install create-dmg)
|
||||
|
||||
# Compila la versión para procesadores Intel con cmake
|
||||
@cmake -S . -B build/intel -DCMAKE_BUILD_TYPE=Release -DCMAKE_OSX_ARCHITECTURES=x86_64 -DCMAKE_OSX_DEPLOYMENT_TARGET=10.15 -DMACOS_BUNDLE=ON
|
||||
@cmake --build build/intel
|
||||
# Verifica dependencias necesarias (create-dmg). Si falta, intenta instalarla
|
||||
# con brew; si brew tampoco está, indica el comando exacto al usuario.
|
||||
@command -v create-dmg >/dev/null 2>&1 || { \
|
||||
echo ""; \
|
||||
echo "============================================"; \
|
||||
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
|
||||
$(RMDIR) "$(RELEASE_FOLDER)"
|
||||
@@ -148,8 +249,8 @@ _macos_release:
|
||||
$(MKDIR) "$(RELEASE_FOLDER)/$(APP_NAME).app/Contents/MacOS"
|
||||
$(MKDIR) "$(RELEASE_FOLDER)/$(APP_NAME).app/Contents/Resources"
|
||||
|
||||
# Copia carpetas y ficheros
|
||||
cp -R data "$(RELEASE_FOLDER)/$(APP_NAME).app/Contents/Resources"
|
||||
# Copia carpetas y ficheros (resources.pack substitueix la carpeta data/)
|
||||
cp build/resources.pack "$(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 release/icons/*.icns "$(RELEASE_FOLDER)/$(APP_NAME).app/Contents/Resources"
|
||||
@@ -163,14 +264,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>CFBundleVersion<\/key>/{n;s|<string>.*</string>|<string>'"$$RAW_VERSION"'</string>|;}' "$(RELEASE_FOLDER)/$(APP_NAME).app/Contents/Info.plist"
|
||||
|
||||
# Copia el ejecutable Intel al bundle
|
||||
cp "$(TARGET_FILE)" "$(RELEASE_FOLDER)/$(APP_NAME).app/Contents/MacOS/$(TARGET_NAME)"
|
||||
|
||||
# Firma la aplicación
|
||||
codesign --deep --force --sign - --timestamp=none "$(RELEASE_FOLDER)/$(APP_NAME).app"
|
||||
|
||||
# Empaqueta el .dmg de la versión Intel con create-dmg
|
||||
@echo "Creando DMG Intel con iconos de 96x96..."
|
||||
# Compila y empaqueta la versión Intel (best-effort: si falla, se omite el
|
||||
# DMG Intel y continúa con la build de Apple Silicon).
|
||||
@echo ""
|
||||
@echo "============================================"
|
||||
@echo " Compilando version Intel (x86_64)"
|
||||
@echo "============================================"
|
||||
@if cmake -S . -B build/intel -DCMAKE_BUILD_TYPE=Release \
|
||||
-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 \
|
||||
--volname "$(APP_NAME)" \
|
||||
--window-pos 200 120 \
|
||||
@@ -183,11 +290,24 @@ _macos_release:
|
||||
--app-drop-link 115 102 \
|
||||
--hide-extension "$(APP_NAME).app" \
|
||||
"$(MACOS_INTEL_RELEASE)" \
|
||||
"$(RELEASE_FOLDER)" || true
|
||||
@echo "Release Intel creado: $(MACOS_INTEL_RELEASE)"
|
||||
"$(RELEASE_FOLDER)" || true; \
|
||||
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
|
||||
@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
|
||||
cp "$(TARGET_FILE)" "$(RELEASE_FOLDER)/$(APP_NAME).app/Contents/MacOS/$(TARGET_NAME)"
|
||||
|
||||
@@ -217,22 +337,64 @@ _macos_release:
|
||||
$(RMDIR) build/arm
|
||||
$(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)
|
||||
# ==============================================================================
|
||||
_linux_release:
|
||||
_linux-release: pack
|
||||
@echo "Creando release para Linux - Version: $(VERSION)"
|
||||
|
||||
# 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
|
||||
|
||||
# Elimina carpeta temporal previa y la recrea (crea dist/ si no existe)
|
||||
$(RMDIR) "$(RELEASE_FOLDER)"
|
||||
$(MKDIR) "$(RELEASE_FOLDER)"
|
||||
|
||||
# Copia ficheros
|
||||
cp -r data "$(RELEASE_FOLDER)"
|
||||
# Copia ficheros (resources.pack substitueix la carpeta data/)
|
||||
cp build/resources.pack "$(RELEASE_FOLDER)"
|
||||
cp LICENSE "$(RELEASE_FOLDER)"
|
||||
cp README.md "$(RELEASE_FOLDER)"
|
||||
cp gamecontrollerdb.txt "$(RELEASE_FOLDER)"
|
||||
@@ -247,4 +409,83 @@ _linux_release:
|
||||
# Elimina la carpeta temporal
|
||||
$(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
|
||||
|
||||
# ==============================================================================
|
||||
# GIT HOOKS
|
||||
# ==============================================================================
|
||||
hooks-install:
|
||||
@git config core.hooksPath .githooks
|
||||
@echo "Git hooks activats: $(shell pwd)/.githooks"
|
||||
|
||||
# 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 " Execucio:"
|
||||
@echo " make run - Compilar (Release) i executar"
|
||||
@echo " make run-debug - Compilar (Debug) i executar"
|
||||
@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 $(BUILDDIR)/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 clean - Esborrar carpeta $(BUILDDIR)/"
|
||||
@echo " make rebuild - clean + all"
|
||||
@echo " make hooks-install - Activar git hooks del projecte"
|
||||
@echo " make help - Mostrar esta ajuda"
|
||||
@echo ""
|
||||
@echo " Versio actual: $(VERSION) ($(GIT_HASH))"
|
||||
|
||||
.PHONY: all debug run run-debug clean rebuild pack release wasm wasm-debug _windows-release _linux-release _macos-release compile-shaders controllerdb format format-check tidy tidy-fix cppcheck hooks-install help
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,47 @@
|
||||
# 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: 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"
|
||||
@@ -4,73 +4,86 @@
|
||||
|
||||
menu:
|
||||
titles:
|
||||
root: "OPCIONS"
|
||||
video: "VIDEO"
|
||||
audio: "AUDIO"
|
||||
controls: "CONTROLS"
|
||||
root: "Opcions"
|
||||
video: "Vídeo"
|
||||
audio: "Àudio"
|
||||
controls: "Controls"
|
||||
game: "Joc"
|
||||
system: "Sistema"
|
||||
|
||||
items:
|
||||
video: "VIDEO"
|
||||
audio: "AUDIO"
|
||||
controls: "CONTROLS"
|
||||
zoom: "ZOOM"
|
||||
screen: "PANTALLA"
|
||||
shader: "SHADER"
|
||||
aspect_4_3: "ASPECTE 4:3"
|
||||
supersampling: "SUPERSAMPLING"
|
||||
vsync: "VSYNC"
|
||||
integer_scale: "ESCALA ENTERA"
|
||||
shader_type: "TIPUS SHADER"
|
||||
preset: "PRESET"
|
||||
stretch_filter: "FILTRE 4:3"
|
||||
render_info: "RENDER INFO"
|
||||
uptime: "TEMPS DE JOC"
|
||||
master_enable: "AUDIO"
|
||||
master_volume: "MASTER"
|
||||
music: "MUSICA"
|
||||
music_volume: "VOL MUSICA"
|
||||
sounds: "SONS"
|
||||
sounds_volume: "VOL SONS"
|
||||
move_up: "MOU AMUNT"
|
||||
move_down: "MOU AVALL"
|
||||
move_left: "MOU ESQUERRA"
|
||||
move_right: "MOU DRETA"
|
||||
menu_key: "TECLA MENU"
|
||||
video: "Vídeo"
|
||||
audio: "Àudio"
|
||||
controls: "Controls"
|
||||
game: "Joc"
|
||||
system: "Sistema"
|
||||
restart: "Reinicia"
|
||||
exit_game: "Eixir del joc"
|
||||
use_new_logo: "Logo nou"
|
||||
show_title_credits: "Crèdits del port"
|
||||
show_preload: "Barra de precàrrega"
|
||||
zoom: "Zoom"
|
||||
screen: "Pantalla"
|
||||
shader: "Shader"
|
||||
aspect_4_3: "Aspecte 4:3"
|
||||
vsync: "Vsync"
|
||||
scaling_mode: "Escala"
|
||||
shader_type: "Tipus shader"
|
||||
preset: "Preset"
|
||||
texture_filter: "Filtre textura"
|
||||
render_info: "Render info"
|
||||
uptime: "Temps de joc"
|
||||
internal_resolution: "Resolució interna"
|
||||
master_enable: "Àudio"
|
||||
master_volume: "Màster"
|
||||
music: "Música"
|
||||
music_volume: "Vol música"
|
||||
sounds: "Sons"
|
||||
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:
|
||||
"yes": "SI"
|
||||
"no": "NO"
|
||||
"on": "ON"
|
||||
"off": "OFF"
|
||||
fullscreen: "COMPLETA"
|
||||
windowed: "FINESTRA"
|
||||
linear: "LINEAR"
|
||||
nearest: "NEAREST"
|
||||
top: "TOP"
|
||||
bottom: "BOTTOM"
|
||||
press_key: "<PREM TECLA>"
|
||||
empty: "(BUIT)"
|
||||
"yes": "Sí"
|
||||
"no": "No"
|
||||
"on": "On"
|
||||
"off": "Off"
|
||||
fullscreen: "Completa"
|
||||
windowed: "Finestra"
|
||||
linear: "Linear"
|
||||
nearest: "Nearest"
|
||||
top: "Top"
|
||||
bottom: "Bottom"
|
||||
press_key: "<Prem tecla>"
|
||||
empty: "(Buit)"
|
||||
unknown: "---"
|
||||
scaling_disabled: "Sense escala"
|
||||
scaling_stretch: "Estirada"
|
||||
scaling_letterbox: "Letterbox"
|
||||
scaling_overscan: "Overscan"
|
||||
scaling_integer: "Entera"
|
||||
|
||||
window:
|
||||
title: "© 2000 Aventures en Egipte — JailDesigner"
|
||||
|
||||
notifications:
|
||||
exit_double_esc: "TORNA A PULSAR ESC PER EIXIR"
|
||||
zoom_fmt: "ZOOM %dX"
|
||||
fullscreen: "PANTALLA COMPLETA"
|
||||
windowed: "FINESTRA"
|
||||
shader_on: "SHADER ON"
|
||||
shader_off: "SHADER OFF"
|
||||
exit_double_esc: "Torna a pulsar ESC per a eixir"
|
||||
zoom_fmt: "Zoom %dX"
|
||||
fullscreen: "Pantalla completa"
|
||||
windowed: "Finestra"
|
||||
shader_on: "Shader on"
|
||||
shader_off: "Shader off"
|
||||
aspect_43: "4:3 CRT"
|
||||
aspect_square: "PIXELS QUADRATS"
|
||||
ss_on: "SUPERSAMPLING ON"
|
||||
ss_off: "SUPERSAMPLING OFF"
|
||||
preset_fmt: "PRESET: %s"
|
||||
filter_linear: "FILTRE: LINEAR"
|
||||
filter_nearest: "FILTRE: NEAREST"
|
||||
pause: "PAUSA"
|
||||
resume: "REPRES"
|
||||
aspect_square: "Píxels quadrats"
|
||||
preset_fmt: "Preset: %s"
|
||||
filter_linear: "Filtre: linear"
|
||||
filter_nearest: "Filtre: nearest"
|
||||
pause: "Pausa"
|
||||
gamepad_connected: "connectat"
|
||||
gamepad_disconnected: "desconnectat"
|
||||
|
||||
credits:
|
||||
port_role: "Conversio a C++ i SDL3"
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
#version 450
|
||||
layout(location = 0) in vec2 v_uv;
|
||||
layout(location = 0) out vec4 out_color;
|
||||
|
||||
layout(set = 2, binding = 0) uniform sampler2D source;
|
||||
|
||||
layout(set = 3, binding = 0) uniform DownscaleUniforms {
|
||||
int algorithm; // 0 = Lanczos2 (ventana 2, ±2 taps), 1 = Lanczos3 (ventana 3, ±3 taps)
|
||||
float pad0;
|
||||
float pad1;
|
||||
float pad2;
|
||||
} u;
|
||||
|
||||
// Kernel Lanczos normalizado: sinc(t) * sinc(t/a) para |t| < a, 0 fuera.
|
||||
float lanczos(float t, float a) {
|
||||
t = abs(t);
|
||||
if (t < 0.0001) { return 1.0; }
|
||||
if (t >= a) { return 0.0; }
|
||||
const float PI = 3.14159265358979;
|
||||
float pt = PI * t;
|
||||
return (a * sin(pt) * sin(pt / a)) / (pt * pt);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 src_size = vec2(textureSize(source, 0));
|
||||
// Posición en coordenadas de texel (centros de texel en N+0.5)
|
||||
vec2 p = v_uv * src_size;
|
||||
vec2 p_floor = floor(p);
|
||||
|
||||
float a = (u.algorithm == 0) ? 2.0 : 3.0;
|
||||
int win = int(a);
|
||||
|
||||
vec4 color = vec4(0.0);
|
||||
float weight_sum = 0.0;
|
||||
|
||||
for (int j = -win; j <= win; j++) {
|
||||
for (int i = -win; i <= win; i++) {
|
||||
// Centro del texel (i,j) relativo a p_floor
|
||||
vec2 tap_center = p_floor + vec2(float(i), float(j)) + 0.5;
|
||||
vec2 offset = tap_center - p;
|
||||
float w = lanczos(offset.x, a) * lanczos(offset.y, a);
|
||||
color += texture(source, tap_center / src_size) * w;
|
||||
weight_sum += w;
|
||||
}
|
||||
}
|
||||
|
||||
out_color = (weight_sum > 0.0) ? (color / weight_sum) : vec4(0.0, 0.0, 0.0, 1.0);
|
||||
}
|
||||
@@ -6,7 +6,9 @@
|
||||
// xxd -i postfx.frag.spv > ../../source/core/rendering/sdl3gpu/postfx_frag_spv.h
|
||||
//
|
||||
// PostFXUniforms must match exactly the C++ struct in sdl3gpu_shader.hpp
|
||||
// (8 floats, 32 bytes, std140/scalar layout).
|
||||
// (16 floats = 4 × vec4 = 64 bytes, std140/scalar layout).
|
||||
// IMPORTANT: Qualsevol canvi ací cal replicar-lo a mà a
|
||||
// source/core/rendering/sdl3gpu/msl/postfx_frag.msl.h (no hi ha generador).
|
||||
|
||||
layout(location = 0) in vec2 v_uv;
|
||||
layout(location = 0) out vec4 out_color;
|
||||
@@ -15,7 +17,7 @@ layout(set = 2, binding = 0) uniform sampler2D scene;
|
||||
|
||||
layout(set = 3, binding = 0) uniform PostFXUniforms {
|
||||
float vignette_strength;
|
||||
float chroma_strength;
|
||||
float chroma_min; // intensitat mínima de l'aberració cromàtica
|
||||
float scanline_strength;
|
||||
float screen_height;
|
||||
float mask_strength;
|
||||
@@ -24,10 +26,28 @@ layout(set = 3, binding = 0) uniform PostFXUniforms {
|
||||
float bleeding;
|
||||
float pixel_scale; // physical pixels per logical pixel (vh / tex_height_)
|
||||
float time; // seconds since SDL init
|
||||
float oversample; // supersampling factor (1.0 = off, 3.0 = 3×SS)
|
||||
float flicker; // 0 = off, 1 = phosphor flicker ~50 Hz — 48 bytes total (3 × 16)
|
||||
float flicker; // 0 = off, 1 = phosphor flicker ~50 Hz
|
||||
float chroma_max; // intensitat màxima; si == chroma_min → chroma estàtic
|
||||
// vec4 #3 — paràmetres de scanlines (exposats per preset YAML)
|
||||
float scan_dark_ratio; // fracció de subfila fosca per fila lògica (1/3 ≈ 0.333)
|
||||
float scan_dark_floor; // multiplicador de brillantor de la subfila fosca
|
||||
float scan_edge_soft; // 0 = step dur; 1 = suavitzat d'1 píxel físic (estil crtpi)
|
||||
float pad3; // padding per tancar a 64 bytes (4 × vec4)
|
||||
} u;
|
||||
|
||||
// Mostreig bilinear horitzontal d'un canal RGB. Evita el "tic-tac" del sampler
|
||||
// NEAREST quan l'offset de chroma és subpíxel: sense interpolar, l'offset
|
||||
// arrodonia entre 1 i 2 píxels i el drift temporal feia un parpelleig discret.
|
||||
float sampleBilinearX(vec2 uv_target, int channel) {
|
||||
vec2 tex_size = vec2(textureSize(scene, 0));
|
||||
float px = uv_target.x * tex_size.x - 0.5;
|
||||
float p_floor = floor(px);
|
||||
float f = px - p_floor;
|
||||
vec4 c0 = texture(scene, vec2((p_floor + 0.5) / tex_size.x, uv_target.y));
|
||||
vec4 c1 = texture(scene, vec2((p_floor + 1.5) / tex_size.x, uv_target.y));
|
||||
return mix(c0[channel], c1[channel], f);
|
||||
}
|
||||
|
||||
// YCbCr helpers for NTSC bleeding
|
||||
vec3 rgb_to_ycc(vec3 rgb) {
|
||||
return vec3(
|
||||
@@ -69,11 +89,11 @@ void main() {
|
||||
vec3 base = texture(scene, uv).rgb;
|
||||
|
||||
// Sangrado NTSC — difuminado horizontal de crominancia.
|
||||
// step = 1 pixel lógico de juego en UV (corrige SS: textureSize.x = game_w * oversample).
|
||||
// step = 1 pixel lógico de juego en UV.
|
||||
vec3 colour;
|
||||
if (u.bleeding > 0.0) {
|
||||
float tw = float(textureSize(scene, 0).x);
|
||||
float step = u.oversample / tw; // 1 pixel lógico en UV
|
||||
float step = 1.0 / tw; // 1 pixel lógico en UV
|
||||
vec3 ycc = rgb_to_ycc(base);
|
||||
vec3 ycc_l2 = rgb_to_ycc(texture(scene, uv - vec2(2.0*step, 0.0)).rgb);
|
||||
vec3 ycc_l1 = rgb_to_ycc(texture(scene, uv - vec2(1.0*step, 0.0)).rgb);
|
||||
@@ -85,10 +105,14 @@ void main() {
|
||||
colour = base;
|
||||
}
|
||||
|
||||
// Aberración cromática (drift animado con time para efecto NTSC real)
|
||||
float ca = u.chroma_strength * 0.005 * (1.0 + 0.15 * sin(u.time * 7.3));
|
||||
colour.r = texture(scene, uv + vec2(ca, 0.0)).r;
|
||||
colour.b = texture(scene, uv - vec2(ca, 0.0)).b;
|
||||
// Aberración cromática — intensitat varia entre chroma_min i chroma_max amb
|
||||
// una sinusoidal (si min == max, queda estàtica). Mostreig bilinear horitzontal
|
||||
// per evitar el "tic-tac" del NEAREST sampler quan l'offset és subpíxel.
|
||||
if (u.chroma_min > 0.0 || u.chroma_max > 0.0) {
|
||||
float ca = mix(u.chroma_min, u.chroma_max, 0.5 + 0.5 * sin(u.time * 7.3)) * 0.005;
|
||||
colour.r = sampleBilinearX(uv + vec2(ca, 0.0), 0);
|
||||
colour.b = sampleBilinearX(uv - vec2(ca, 0.0), 2);
|
||||
}
|
||||
|
||||
// Corrección gamma (linealizar antes de scanlines, codificar después)
|
||||
if (u.gamma_strength > 0.0) {
|
||||
@@ -96,22 +120,20 @@ void main() {
|
||||
colour = mix(colour, lin, u.gamma_strength);
|
||||
}
|
||||
|
||||
// Scanlines — proporción 2/3 brillantes + 1/3 oscuras por fila lógica.
|
||||
// Casos especiales: 1 subfila → sin efecto; 2 subfilas → 1+1 (50/50).
|
||||
// Constantes ajustables:
|
||||
const float SCAN_DARK_RATIO = 0.333; // fracción de subfilas oscuras (ps >= 3)
|
||||
const float SCAN_DARK_FLOOR = 0.42; // multiplicador de brillo de subfilas oscuras
|
||||
// Scanlines — tècnica dels 3 subpíxels verticals per píxel lògic (aee/projecte_2026):
|
||||
// franja fosca ocupant `scan_dark_ratio` al final de cada fila lògica. La transició es
|
||||
// suavitza amb smoothstep d'ample ≈ 1 píxel físic (estil crtpi: filtratge analític
|
||||
// continu), controlat per `scan_edge_soft`. A 0 és equivalent al step dur antic.
|
||||
if (u.scanline_strength > 0.0) {
|
||||
float ps = max(1.0, round(u.pixel_scale));
|
||||
float frac_in_row = fract(uv.y * u.screen_height);
|
||||
float row_pos = floor(frac_in_row * ps);
|
||||
// bright_rows: cuántas subfilas son brillantes
|
||||
// ps==1 → ps (todo brillante → is_dark nunca se activa)
|
||||
// ps==2 → 1 brillante + 1 oscura
|
||||
// ps>=3 → floor(ps * (1 - DARK_RATIO)) brillantes
|
||||
float bright_rows = (ps < 2.0) ? ps : ((ps < 3.0) ? 1.0 : floor(ps * (1.0 - SCAN_DARK_RATIO)));
|
||||
float is_dark = step(bright_rows, row_pos);
|
||||
float scan = mix(1.0, SCAN_DARK_FLOOR, is_dark);
|
||||
float ps = max(u.pixel_scale, 1.0);
|
||||
float sub = fract(uv.y * u.screen_height); // [0,1) dins la fila lògica
|
||||
float dark_center = 1.0 - u.scan_dark_ratio * 0.5; // centre de la franja fosca
|
||||
float d = abs(sub - dark_center);
|
||||
d = min(d, 1.0 - d); // wrap a la fila següent
|
||||
float half_width = u.scan_dark_ratio * 0.5;
|
||||
float softness = u.scan_edge_soft * 0.5 / ps; // mig píxel físic a cada costat
|
||||
float band = 1.0 - smoothstep(half_width - softness, half_width + softness, d);
|
||||
float scan = mix(1.0, u.scan_dark_floor, band);
|
||||
colour *= mix(1.0, scan, u.scanline_strength);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
# Arquitectura de **Aventures en Egipte (AEE)**
|
||||
|
||||
> Guía de orientación para un desarrollador nuevo en el proyecto.
|
||||
>
|
||||
> Cada afirmación está anclada a código real: se cita el fichero (y, cuando
|
||||
> ayuda, la función o el número de línea) que la respalda. Donde no he
|
||||
> encontrado algo, lo digo explícitamente en lugar de inventarlo.
|
||||
>
|
||||
> **Aventures en Egipte** es un juego de aventura/plataformas retro (el
|
||||
> protagonista "Sam" explora pirámides esquivando momias y recogiendo
|
||||
> tesoros), escrito en C++ + SDL3. Resolución de juego **320×200**. El rasgo
|
||||
> que más condiciona el código es que está **en plena migración** desde un
|
||||
> motor legacy propio ("Jail") hacia C++ moderno (ver
|
||||
> [§3](#3-el-motor-legacy-jail)). Los comentarios del código están en
|
||||
> valenciano/español; este documento está en castellano.
|
||||
>
|
||||
> Existe además un `docs/scenes-migration-plan.md` (histórico) sobre la
|
||||
> migración del sistema de escenas; aquí se documenta el **estado actual** del
|
||||
> código, no el plan.
|
||||
|
||||
---
|
||||
|
||||
## Índice
|
||||
|
||||
1. [Visión general](#1-visión-general)
|
||||
2. [Punto de entrada y bucle principal](#2-punto-de-entrada-y-bucle-principal)
|
||||
3. [El motor legacy "Jail"](#3-el-motor-legacy-jail)
|
||||
4. [Escenas y máquina de estados](#4-escenas-y-máquina-de-estados)
|
||||
5. [Renderizado: de la lógica al píxel](#5-renderizado-de-la-lógica-al-píxel)
|
||||
6. [Entrada](#6-entrada)
|
||||
7. [Gameplay: `ModuleGame` y entidades](#7-gameplay-modulegame-y-entidades)
|
||||
8. [Sistema de escenas cinemáticas](#8-sistema-de-escenas-cinemáticas)
|
||||
9. [Recursos](#9-recursos)
|
||||
10. [Audio y localización](#10-audio-y-localización)
|
||||
11. [Configuración y persistencia](#11-configuración-y-persistencia)
|
||||
12. [Modo demo / IA](#12-modo-demo--ia)
|
||||
13. [Convenciones y patrones recurrentes](#13-convenciones-y-patrones-recurrentes)
|
||||
14. [Guía de navegación: "si quieres tocar X, mira Y"](#14-guía-de-navegación-si-quieres-tocar-x-mira-y)
|
||||
|
||||
---
|
||||
|
||||
## 1. Visión general
|
||||
|
||||
El árbol `source/` separa **motor** y **juego**, pero con una particularidad:
|
||||
el motor tiene **dos generaciones** conviviendo (legacy "Jail" + capas nuevas).
|
||||
|
||||
- **`source/core/jail/`** — el **motor legacy "Jail"** (APIs C planas): `Jg`
|
||||
(jgame), `Jd8` (jdraw8), `Ji` (jinput), `Jf` (jfile). En modernización (§3).
|
||||
- **`source/core/`** (resto) — capas **nuevas**: `rendering` (`Screen`,
|
||||
`Overlay`, `Text`, `Menu`, `sdl3gpu`), `input`, `system` (`Director`),
|
||||
`resources`, `audio`, `locale`.
|
||||
- **`source/game/`** — el juego: gameplay legacy en la raíz (`modulegame`,
|
||||
`prota`, `momia`, `engendro`, `bola`, `mapa`, `marcador`, `info`, `sprite`) y
|
||||
el sistema de **escenas** nuevo en `scenes/`.
|
||||
- **`source/utils/`** — `easing`, `utils`.
|
||||
- **`source/external/`** — vendorizado (stb, fkyaml).
|
||||
|
||||
~117 ficheros C++, ~40.000 líneas.
|
||||
|
||||
**El `CLAUDE.md` define una frontera explícita "Original vs New Code"** (§79):
|
||||
`core/jail/` y `game/*.cpp` son *legacy en modernización* (modificar con
|
||||
cuidado, preservando comportamiento); `core/rendering`, `core/input`,
|
||||
`utils/`, `options/defines/defaults` son *código nuevo*. Interiorizar esa
|
||||
frontera es lo primero para no romper invariantes del juego original.
|
||||
|
||||
**Ideas-fuerza:**
|
||||
|
||||
1. Un solo hilo, **tick-based**, vía callbacks de SDL3 (§2).
|
||||
2. El render es **software paletizado 8-bit** (`Jd8`, 320×200) que al final se
|
||||
convierte a ARGB y pasa por shaders GPU (§5).
|
||||
3. El flujo de pantallas es una **máquina de estados** apoyada en
|
||||
`Info::ctx.num_piramide`, con un `SceneRegistry` que va sustituyendo
|
||||
cinemáticas legacy por escenas nuevas (§4).
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
SDL[SDL3 callbacks · main.cpp] --> DIR[Director]
|
||||
DIR -->|game_state_ + Info::ctx| DISP{createNextScene}
|
||||
DISP -->|==0| MG["ModuleGame (gameplay)"]
|
||||
DISP -->|==1| REG["SceneRegistry::tryCreate(num_piramide)"]
|
||||
REG --> CINE["intro / banner / slides / mort / secreta / credits…"]
|
||||
MG --> MAPA[Mapa] & PROTA[Prota] & MOMIA[Momia] & BOLA[Bola] & MARC[Marcador]
|
||||
DIR --> INP["KeyConfig / GlobalInputs / Gamepad / KeyRemap / Mouse"]
|
||||
MG -->|dibuja índices| JD8["Jd8 (8-bit 320×200)"]
|
||||
CINE --> JD8
|
||||
JD8 -->|JD8_Flip → ARGB| SCREEN[Screen]
|
||||
OVL[Overlay] --> SCREEN
|
||||
SCREEN --> GPU["sdl3gpu PostFX / CrtPi / upscale"] --> WIN[Ventana]
|
||||
RES["Resource::Cache / List"] -.-> MG & CINE
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Punto de entrada y bucle principal
|
||||
|
||||
### 2.1. SDL conduce el bucle (callbacks)
|
||||
|
||||
`source/main.cpp` define `SDL_MAIN_USE_CALLBACKS`. No hay `while` propio (clave
|
||||
para el port a Emscripten). `SDL_AppInit` monta todo y `SDL_AppIterate` llama a
|
||||
`Director::iterate()`.
|
||||
|
||||
El arranque en `SDL_AppInit` inicializa, en orden: carpeta de config (`Jf`),
|
||||
sistema de recursos (`resources.pack` + fallback en Debug/WASM), `Options`,
|
||||
`KeyConfig`, `Locale`, presets de shaders, y luego el **motor**: `Jg::init`,
|
||||
`Screen::init`, `Jd8::init`, `Audio::init`, `Overlay::init`, `Menu::init`,
|
||||
`Resource::List`/`Cache` (con `beginLoad()`), y `Director::init` + `setup()`.
|
||||
|
||||
### 2.2. El `Director`
|
||||
|
||||
`source/core/system/director.{hpp,cpp}`. Es el **orquestador singleton, único
|
||||
hilo del runtime** (sin fibers, mutex ni condition variables; el comentario de
|
||||
`director.hpp:11` lo subraya). Posee el estado de escena como miembros:
|
||||
`current_scene_` (`std::unique_ptr<Scenes::Scene>`), `game_state_`,
|
||||
`last_tick_ms_`, y dos buffers de frame `[320*200]` (`game_frame_`,
|
||||
`presentation_buffer_`).
|
||||
|
||||
Cada `iterate()` (modelo documentado en `CLAUDE.md:161`):
|
||||
|
||||
```
|
||||
Gamepad/KeyRemap/GlobalInputs/Mouse::update
|
||||
JA_Update() ← bombeo de audio
|
||||
if (!paused_) {
|
||||
if (scene && (scene->done() || JG_Quitting())) game_state_ = scene->nextState(); scene.reset();
|
||||
if (!scene) scene = createNextScene(); scene->onEnter(); ← ModuleGame o SceneRegistry
|
||||
JI_Update()
|
||||
scene->tick(now - last_tick_ms_)
|
||||
JD8_Flip() ← índices → ARGB
|
||||
memcpy → game_frame
|
||||
}
|
||||
memcpy game_frame → presentation_buffer
|
||||
Overlay::render(presentation_buffer)
|
||||
Screen::present(presentation_buffer)
|
||||
SDL_Delay(frame_target - elapsed) ← cap de FPS
|
||||
```
|
||||
|
||||
### 2.3. Tiempo, pausa y salida
|
||||
|
||||
Las escenas reciben `delta_ms` real (time-based). El gameplay (`ModuleGame`)
|
||||
gatea su `Update()` a 10 ms fijos vía `Jg::shouldUpdate()` (ticker del motor
|
||||
legacy, ya sin *yield*). La **pausa** (F11) simplemente salta el bloque de
|
||||
`tick()`: el overlay y el present siguen, re-presentando el último frame
|
||||
congelado. La **salida** se solicita con `requestQuit()` (doble ESC o
|
||||
`SDL_QUIT`) y `requestRestart()` hace un reinicio suave (para audio, resetea
|
||||
`Info::ctx`, vuelve a la intro).
|
||||
|
||||
---
|
||||
|
||||
## 3. El motor legacy "Jail"
|
||||
|
||||
`source/core/jail/` es el sustrato heredado, **APIs C planas con prefijo por
|
||||
subsistema** (sin clases), que se está convirtiendo progresivamente a C++
|
||||
idiomático manteniendo los nombres externos estables para no romper los
|
||||
*call sites* (`CLAUDE.md:92`). Es el rasgo más distintivo del proyecto.
|
||||
|
||||
| Subsistema | Fichero | Qué hace |
|
||||
|---|---|---|
|
||||
| **`Jg`** (jgame) | `jail/jgame.*` | *Timing*: init/finalize, fixed-timestep (`Jg::shouldUpdate()` a 10 ms), flag de salida (`JG_Quitting`). |
|
||||
| **`Jd8`** (jdraw8) | `jail/jdraw8.*` | **Renderer software 8-bit paletizado**, buffer de pantalla 320×200 (`Jd8::Surface = Uint8*`), blits con *color key*, fades no bloqueantes (máquina de estados), `flip()` (paleta→ARGB) → `Screen::present`. |
|
||||
| **`Ji`** (jinput) | `jail/jinput.*` | Sondeo de teclado, *debounce*, códigos *cheat*. Filtra las teclas de GUI del juego y llama a `GlobalInputs`/`Mouse` cada update. |
|
||||
| **`Jf`** (jfile) | `jail/jfile.*` | I/O de ficheros: carpeta `data/` o pack; carpeta de config en `~/.config/jailgames/aee/`. |
|
||||
|
||||
### `Jd8` en detalle (el corazón del render)
|
||||
|
||||
`jail/jdraw8.hpp`. API representativa:
|
||||
- `using Surface = Uint8*` (búfer de índices de paleta), `using Palette = Color*`.
|
||||
- `loadSurface`/`newSurface`/`freeSurface`, `loadPalette`/`setScreenPalette`.
|
||||
- Blits: `blit`, `blitCK` (con *color key*/transparencia), `blitCKCut`,
|
||||
`blitCKScroll`, `blitToSurface`/`blitCKToSurface` (blit entre surfaces).
|
||||
- `fillRect`/`fillSquare`/`putPixel`/`getPixel`.
|
||||
- **Fade no bloqueante** (`fadeStart*` + `fadeTickStep` que devuelve `true` al
|
||||
acabar): sustituye los `JD8_FadeOut` bloqueantes del código original.
|
||||
- `flip()` convierte el buffer indexado + paleta a ARGB y delega en
|
||||
`Screen::present`; `getFramebuffer()` devuelve el `Uint32*` resultante.
|
||||
|
||||
> Mentalidad clave: **todo el dibujo del juego ocurre sobre índices de 8 bits**
|
||||
> en un buffer 320×200; el color real y los shaders entran solo al final.
|
||||
|
||||
---
|
||||
|
||||
## 4. Escenas y máquina de estados
|
||||
|
||||
### 4.1. La base `Scene`
|
||||
|
||||
`source/game/scenes/scene.hpp` (`namespace Scenes`):
|
||||
|
||||
```cpp
|
||||
class Scene {
|
||||
public:
|
||||
virtual void onEnter() {} // una vez, antes del primer tick
|
||||
virtual void tick(int delta_ms) = 0; // no bloquea, NO llama a Jd8::flip
|
||||
virtual auto done() const -> bool = 0;
|
||||
virtual auto nextState() const -> int { return 1; } // 1=siguiente, 0=gameplay, -1=salir
|
||||
};
|
||||
```
|
||||
|
||||
El `Director` hace avanzar la escena hasta que `done()` es cierto, consulta
|
||||
`nextState()` y construye la siguiente.
|
||||
|
||||
### 4.2. El despachador: `game_state_` + `Info::ctx`
|
||||
|
||||
`Director::createNextScene()` decide la siguiente escena:
|
||||
- `game_state_ == 0` → `new ModuleGame` (gameplay puro, §7).
|
||||
- `game_state_ == 1` → `SceneRegistry::tryCreate(Info::ctx.num_piramide)`
|
||||
(`game/scenes/scene_registry.hpp`): un mapa `int → factory` que devuelve la
|
||||
escena registrada para ese estado, o `nullptr` para caer al **path legacy**.
|
||||
Replica el viejo `ModuleSequence::Go`, incluido el *redirect* heredado
|
||||
`num_piramide == 6 && diners < 200 → 7` (`CLAUDE.md:104`).
|
||||
- `game_state_ == -1` → salir.
|
||||
|
||||
### 4.3. `Info::ctx`: el estado del juego
|
||||
|
||||
`source/game/info.hpp` — `Info::GameContext ctx` (singleton inline) es la fuente
|
||||
de verdad del estado:
|
||||
|
||||
```cpp
|
||||
struct GameContext {
|
||||
int num_piramide, num_habitacio, diners, diamants, vida, momies, engendros;
|
||||
bool nou_personatge, pepe_activat;
|
||||
void reset();
|
||||
};
|
||||
```
|
||||
|
||||
`num_piramide` es la pieza central: hace de **selector tanto de cinemáticas como
|
||||
de nivel jugable**. El `SceneRegistry` va migrando cada estado de cinemática a
|
||||
una `Scene` nueva; lo que no esté registrado cae a legacy. Por eso el registro
|
||||
"crece" a medida que avanza la modernización.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
SC[Scene activa] -->|done()| NS{nextState}
|
||||
NS -->|0| MG[ModuleGame]
|
||||
NS -->|1| REG["SceneRegistry(num_piramide)"]
|
||||
NS -->|-1| QUIT[salir]
|
||||
MG -->|muta Info::ctx| REG
|
||||
REG --> SC
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Renderizado: de la lógica al píxel
|
||||
|
||||
El pipeline tiene dos mitades: **software paletizado** (`Jd8`) y **GPU**
|
||||
(`Screen` + shaders).
|
||||
|
||||
### 5.1. Software: dibujar índices
|
||||
|
||||
Escenas y `ModuleGame` dibujan sobre el buffer `screen` de `Jd8` (índices 8-bit,
|
||||
320×200) con blits y *color key*. `JD8_Flip()` aplica la paleta y produce un
|
||||
buffer ARGB (formato **ABGR8888**: `0xFF000000 + R + (G<<8) + (B<<16)`;
|
||||
`CLAUDE.md:220`).
|
||||
|
||||
### 5.2. `Overlay`: sobre el buffer ARGB
|
||||
|
||||
`source/core/rendering/overlay.*` pinta **directamente sobre el buffer ARGB**
|
||||
antes de presentar: notificaciones (slide-in), info de render animada (4
|
||||
segmentos), indicador de **PAUSA** y la lógica de **doble-ESC para salir**.
|
||||
|
||||
### 5.3. `Screen`: a la ventana (GPU o fallback)
|
||||
|
||||
`source/core/rendering/screen.*` (singleton). Doble camino
|
||||
(`Screen::present`, `CLAUDE.md:204`):
|
||||
- **GPU con shaders** (primario, `sdl3gpu/`): sube los píxeles a una textura
|
||||
de escena 320×200; opcionalmente *upscale*/**supersampling** (3×/6×/9× con
|
||||
*downscale* Lanczos) y **estiramiento 4:3** fusionado en el *upscale*; luego
|
||||
el shader **PostFX** o **CRT-Pi** (con presets) a la *swapchain* con
|
||||
*letterboxing*.
|
||||
- **GPU sin shaders**: subida limpia a la *swapchain*.
|
||||
- **Fallback**: `SDL_UpdateTexture` + `SDL_RenderPresent` (`SDL_Renderer`).
|
||||
|
||||
`Screen` gestiona ventana, fullscreen, zoom, 4:3, *integer scaling*, VSync, FPS.
|
||||
Apoyos de UI: `Text` (fuentes bitmap `.fnt`+`.gif` sobre el buffer ARGB, con
|
||||
*clipping* 2D) y `Menu` (menú flotante de opciones con navegación por páginas,
|
||||
animaciones y captura de teclas para *remapping*).
|
||||
|
||||
---
|
||||
|
||||
## 6. Entrada
|
||||
|
||||
Toda la entrada de UI/sistema converge en **`KeyConfig`** como fuente única de
|
||||
verdad de las teclas (`source/core/input/key_config.*`): carga
|
||||
`data/input/keys.yaml` (F1–F10 GlobalInputs + F11 pausa + F12 menú) y aplica
|
||||
*overrides* del usuario; expone `scancode("id")`, `isGuiKey(sc)` (para que el
|
||||
`Director` no propague teclas de UI al juego), y persiste solo lo que difiere
|
||||
del default.
|
||||
|
||||
- **`GlobalInputs`** (`global_inputs.*`) — mapea las F-keys a acciones de
|
||||
presentación (zoom, fullscreen, shaders, 4:3, supersampling, filtro, render
|
||||
info, pausa, menú). Tabla completa en `CLAUDE.md:139`.
|
||||
- **`Gamepad`** (`gamepad.*`) — primer mando con *hot-plug* y notificación;
|
||||
D-pad/stick → flechas virtuales; botones frontales → Enter sintético;
|
||||
SELECT → menú, START → pausa. Carga `gamecontrollerdb.txt`.
|
||||
- **`KeyRemap`** (`key_remap.*`) — cada frame copia `Options::keys_game.*` al
|
||||
estado virtual de scancodes estándar (UP/DOWN/LEFT/RIGHT). Permite remapear el
|
||||
movimiento **sin tocar el código legacy** de `prota.cpp`/`mapa.cpp`.
|
||||
- **`Mouse`** (`mouse.*`) — auto-oculta el cursor tras 3 s de inactividad.
|
||||
|
||||
> Reparto deliberado: las teclas de **UI/sistema** viven en `KeyConfig`
|
||||
> (`keys.yaml`); las de **movimiento del jugador** en `Options::keys_game`
|
||||
> (`config.yaml`, sección `controls:`).
|
||||
|
||||
---
|
||||
|
||||
## 7. Gameplay: `ModuleGame` y entidades
|
||||
|
||||
`source/game/modulegame.{hpp,cpp}` es la **escena de gameplay puro**
|
||||
(`game_state_ == 0`). Hereda de `Scenes::Scene` y sustituye el viejo `Go()`
|
||||
bloqueante por un `tick()`. Tres fases internas (`Phase`):
|
||||
`FADING_IN → PLAYING → FADING_OUT → DONE` (`modulegame.hpp:46`), con un
|
||||
`PaletteFade` para los fundidos no bloqueantes.
|
||||
|
||||
Coordina las entidades del nivel, todas con una `Jd8::Surface gfx_` compartida:
|
||||
- **`Mapa`** (`game/mapa.*`) — la sala/pirámide: tiles con contenido
|
||||
(`CONTE_RES`/`CONTE_TRESOR`/`CONTE_FARAO`…), colisión y dibujo del escenario.
|
||||
- **`Prota`** (`game/prota.*`) — el protagonista "Sam" (hereda de `Sprite`),
|
||||
movimiento y colisión.
|
||||
- **`Momia`** (`game/momia.*`) — enemigo que persigue al `Prota` (recibe
|
||||
`Prota*`); variante `dimoni` (demonio).
|
||||
- **`Bola`** (`game/bola.*`) — bola/roca que interactúa con el `Prota`.
|
||||
- **`Engendro`** (`game/engendro.*`) — entidad generada (su `update()` devuelve
|
||||
`bool`); `Info::ctx.engendros` lleva la cuenta.
|
||||
- **`Marcador`** (`game/marcador.*`) — el marcador (vidas, dinero, diamantes),
|
||||
lee del `Prota`/`Info::ctx`.
|
||||
|
||||
`ModuleGame::tick()` hace `draw()` (a `screen`, sin `flip` — lo hace el
|
||||
`Director`) y `update()` (gateado por `Jg::shouldUpdate`). Cuando la partida
|
||||
acaba (`final_ != 0`: muerte o cambio de sala), `applyFinalTransitions()` muta
|
||||
`Info::ctx` y `nextState()` devuelve el estado siguiente.
|
||||
|
||||
Todas las entidades de gameplay derivan de **`Sprite`** (`game/sprite.*`),
|
||||
clase base con `draw()`/`update()` sobre `Jd8`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Sistema de escenas cinemáticas
|
||||
|
||||
Las pantallas no jugables (`game_state_ == 1`) son `Scene`s registradas en el
|
||||
`SceneRegistry`. Las concretas viven en `source/game/scenes/`: `boot_loader`
|
||||
(carga incremental, §9), `banner`, `intro`, `intro_new_logo`, `intro_sprites`,
|
||||
`slides`, `menu` (`menu_scene`), `mort` (secuencia de muerte), `secreta`
|
||||
(pantalla secreta) y `credits`.
|
||||
|
||||
Sobre ellas hay un **conjunto de utilidades de animación** reutilizables —el
|
||||
andamiaje "cinematográfico" del juego—:
|
||||
- **`Timeline`** (`scenes/timeline.*`) — secuenciación temporal de eventos.
|
||||
- **`FrameAnimator`** (`scenes/frame_animator.*`) — animación por fotogramas.
|
||||
- **`SpriteMover`** (`scenes/sprite_mover.*`) — movimiento interpolado de
|
||||
sprites (con `easing`).
|
||||
- **`PaletteFade`** (`scenes/palette_fade.*`) — fundidos por paleta no
|
||||
bloqueantes (también usado por `ModuleGame`).
|
||||
- **`SurfaceHandle`** (`scenes/surface_handle.*`) — propiedad RAII de una
|
||||
`Jd8::Surface` (las escenas poseen sus assets y los liberan en el destructor).
|
||||
- **`scene_utils`** — helpers compartidos.
|
||||
|
||||
---
|
||||
|
||||
## 9. Recursos
|
||||
|
||||
- **`Resource::List`** (`core/resources/resource_list.*`) — registro de rutas de
|
||||
asset desde **`data/config/assets.yaml`**, con consulta O(1).
|
||||
- **`Resource::Cache`** (`core/resources/resource_cache.*`) — caché de assets con
|
||||
**carga incremental**: `beginLoad()` (en `SDL_AppInit`) + la escena
|
||||
`BootLoaderScene` que el `Director` arranca automáticamente mientras
|
||||
`Resource::Cache::isLoadDone()` sea falso (una barra de progreso con la
|
||||
ventana viva).
|
||||
- **Pack y fallback**: `resource_pack.*` + `resource_helper.*` sirven desde
|
||||
**`resources.pack`** (formato propio "AEE1", `CLAUDE.md:237`); en release
|
||||
nativo es estricto (solo pack), en Debug/WASM hay *fallback* a `data/`.
|
||||
- **Formatos**: GIF/paletas para gráficos (vía `Jd8`), fuentes `.fnt`+`.gif`,
|
||||
OGG/WAV para audio, GLSL para shaders.
|
||||
|
||||
---
|
||||
|
||||
## 10. Audio y localización
|
||||
|
||||
- **Audio**: `core/audio/audio.*` (singleton, aplica `Options::audio`) sobre
|
||||
**`jail_audio`** (`Ja`, `jail/jail_audio.hpp` / `core/audio/jail_audio.hpp`),
|
||||
mezcla propia con streams SDL3 (OGG vía `stb_vorbis`, WAV). `JA_Update()` se
|
||||
bombea cada frame desde el `Director`; pausa/resume con F11.
|
||||
- **Localización**: `core/locale/locale.*` — mapa plano clave→cadena cargado de
|
||||
YAML (`data/locale/ca.yaml`, valenciano por defecto). Claves con notación de
|
||||
puntos (`menu.items.zoom`); si falta una clave, devuelve la propia clave
|
||||
(fallback visible para depurar).
|
||||
|
||||
---
|
||||
|
||||
## 11. Configuración y persistencia
|
||||
|
||||
- **`Options`** (`source/game/options.*`) — namespace con globals `inline` y
|
||||
carga/guardado YAML. Structs: `KeysGame` (movimiento), `Video`, `RenderInfo`,
|
||||
`Audio`, `Window`, `Game`, `PostFXPreset`, `CrtPiPreset`.
|
||||
- **`defines.hpp`** — constantes del juego: `Texts::WINDOW_TITLE`/`VERSION`,
|
||||
`GameScreen::WIDTH=320`/`HEIGHT=200`/`BUFFER_SIZE=64000`.
|
||||
- **`defaults.hpp`** — valores por defecto (`Defaults::KeysGame`, `Video`,
|
||||
`Audio`, `Window`, `Game`).
|
||||
- **`KeyConfig`** — teclas de UI (§6).
|
||||
|
||||
Ficheros persistentes en `~/.config/jailgames/aee/` (`CLAUDE.md:224`):
|
||||
`config.yaml` (vídeo/audio/ventana/render_info/controles), `keys.yaml`
|
||||
(overrides de UI), `postfx.yaml` (6 presets), `crtpi.yaml` (4 presets), y en
|
||||
Debug `debug.yaml` (estado inicial de gameplay para pruebas).
|
||||
|
||||
**Builds condicionales**: `NDEBUG` (release: pack estricto, sin `debug.yaml`),
|
||||
`__EMSCRIPTEN__` (WASM: fuerza ventana/zoom/4:3, mantiene fallback de assets,
|
||||
sin persistencia MEMFS).
|
||||
|
||||
---
|
||||
|
||||
## 12. Modo demo / IA
|
||||
|
||||
**No he encontrado un modo demo, *attract mode* ni IA de demostración en este
|
||||
proyecto.** Lo he buscado explícitamente: la única aparición de "replay" es un
|
||||
comentario en `jail_audio.hpp` sobre re-reproducción de pistas de audio, sin
|
||||
relación con un modo demo.
|
||||
|
||||
A diferencia de los proyectos hermanos `coffee_crisis` (playback de input
|
||||
grabado) o `jaildoctors_dilemma` (tour de habitaciones), aquí el flujo de
|
||||
atracción —si lo hubiera— se construiría sobre el `SceneRegistry` y las escenas
|
||||
cinemáticas (§8), pero **hoy no existe** tal cosa implementada.
|
||||
|
||||
---
|
||||
|
||||
## 13. Convenciones y patrones recurrentes
|
||||
|
||||
- **Frontera legacy/nuevo** (`CLAUDE.md:79`): lo más importante. `core/jail/` y
|
||||
`game/*.cpp` son legacy en modernización (preservar comportamiento); el resto
|
||||
es código nuevo. Los assets de `data/gfx`/`data/music` son **intocables**.
|
||||
- **Namespaces legacy abreviados** `Jg`/`Jd8`/`Ji`/`Jf`/`Ja` (APIs C planas),
|
||||
con nombres externos estables durante la transición.
|
||||
- **Single-thread, tick-based**: sin fibers/mutex; las escenas no bloquean ni
|
||||
llaman a `Jd8::flip` (lo hace el `Director`).
|
||||
- **Render paletizado 8-bit** sobre buffer 320×200; color y shaders solo al
|
||||
final (`JD8_Flip` → `Screen`).
|
||||
- **Singletons con `init()`/`destroy()`/`get()`** (`Screen`, `Audio`,
|
||||
`Director`, `Menu`, `Overlay`, `Resource::*`, `Jd8`/`Jg` como módulos),
|
||||
creados/destruidos en orden explícito en `SDL_AppInit`/`SDL_AppQuit`.
|
||||
- **Máquina de estados por `Info::ctx.num_piramide`** + `SceneRegistry` que
|
||||
migra cinemáticas legacy a `Scene`s nuevas de forma incremental.
|
||||
- **Fades y timing no bloqueantes**: lo que antes eran bucles bloqueantes
|
||||
(`JD8_FadeOut`) ahora son máquinas de estados que avanzan un paso por tick.
|
||||
- **Comentarios** en valenciano/español; muchos `#include` con comentario de
|
||||
justificación (estilo IWYU).
|
||||
|
||||
---
|
||||
|
||||
## 14. Guía de navegación: "si quieres tocar X, mira Y"
|
||||
|
||||
| Quiero… | Empieza por… |
|
||||
|---|---|
|
||||
| Entender el arranque | `source/main.cpp` (`SDL_AppInit`) + `core/system/director.cpp` |
|
||||
| El bucle / orden del frame | `Director::iterate()` (`director.cpp`) |
|
||||
| Tocar el motor de dibujo | `core/jail/jdraw8.*` (`Jd8`) |
|
||||
| Timing / fixed-timestep | `core/jail/jgame.*` (`Jg::shouldUpdate`) |
|
||||
| Añadir/editar una pantalla | `game/scenes/` (hereda de `Scenes::Scene`) + `SceneRegistry::registerScene` |
|
||||
| Cómo se decide la siguiente pantalla | `Director::createNextScene` + `Info::ctx.num_piramide` + `scene_registry.*` |
|
||||
| El estado del juego | `game/info.hpp` (`Info::ctx`) |
|
||||
| **Gameplay** | `game/modulegame.*` (fases) + entidades `prota/momia/bola/engendro/mapa/marcador` |
|
||||
| Animaciones de cinemáticas | `game/scenes/{timeline,frame_animator,sprite_mover,palette_fade,surface_handle}.*` |
|
||||
| Composición final / shaders | `core/rendering/screen.*` + `sdl3gpu/` + presets `postfx.yaml`/`crtpi.yaml` |
|
||||
| Overlays (notificaciones/PAUSA/info) | `core/rendering/overlay.*` |
|
||||
| Texto / menú | `core/rendering/text.*`, `core/rendering/menu.*` |
|
||||
| Teclas de UI / F1–F12 | `core/input/key_config.*` + `global_inputs.*` + `data/input/keys.yaml` |
|
||||
| Remapear movimiento | `core/input/key_remap.*` + `Options::keys_game` |
|
||||
| Mando | `core/input/gamepad.*` |
|
||||
| Cargar recursos / barra de carga | `core/resources/resource_cache.*` + `resource_list.*` + `scenes/boot_loader_scene.*` |
|
||||
| Audio | `core/audio/audio.*` + `jail_audio.hpp` (`Ja`) |
|
||||
| Idiomas | `core/locale/locale.*` + `data/locale/` |
|
||||
| Opciones / constantes | `game/options.*`, `game/defines.hpp`, `game/defaults.hpp` |
|
||||
| Carpetas y pack | `core/jail/jfile.*` (`Jf`) + `core/resources/resource_pack.*` |
|
||||
|
||||
---
|
||||
|
||||
*Documento generado a partir de la lectura directa del código en el commit
|
||||
actual de la rama `main`. Si algo aquí no cuadra con el código, el código
|
||||
manda: actualiza este documento.*
|
||||
@@ -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 (~900–1100 frames cada una), 6–8 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 0–7b 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: **~20–80 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 | 1–9 | una por paso |
|
||||
| [source/core/system/director.cpp](source/core/system/director.cpp) | 0 | modificar `gameFiberEntry` |
|
||||
| [source/game/modulesequence.cpp](source/game/modulesequence.cpp) | 1–9 | borrar funciones `doX()` una a una |
|
||||
| [CMakeLists.txt](CMakeLists.txt) | 0–9 | 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 (0–9) se cierra con build verde + validación visual antes de arrancar el siguiente. No encadeno pasos automáticamente.
|
||||
@@ -0,0 +1,213 @@
|
||||
#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" // NOLINT(bugprone-suspicious-include): stb header-only library
|
||||
// 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::MusicState::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* 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* 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::MusicState ja_state = Ja::getMusicState();
|
||||
switch (ja_state) {
|
||||
case Ja::MusicState::PLAYING:
|
||||
return MusicState::PLAYING;
|
||||
case Ja::MusicState::PAUSED:
|
||||
return MusicState::PAUSED;
|
||||
case Ja::MusicState::STOPPED:
|
||||
case Ja::MusicState::INVALID:
|
||||
case Ja::MusicState::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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
#pragma once
|
||||
|
||||
#include <cmath> // Para std::lround
|
||||
#include <cstdint> // Para int8_t, uint8_t
|
||||
#include <memory> // Para std::unique_ptr
|
||||
#include <string> // Para string
|
||||
#include <utility> // Para move
|
||||
|
||||
namespace Ja {
|
||||
struct Music;
|
||||
struct Sound;
|
||||
} // namespace Ja
|
||||
|
||||
// --- Clase Audio: gestor de audio (singleton) ---
|
||||
// Implementació canònica, byte-idèntica entre projectes.
|
||||
// Els volums es manegen internament com a float 0.0–1.0; la capa de
|
||||
// presentació (menús, notificacions) usa les helpers toPercent/fromPercent
|
||||
// per mostrar 0–100 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(Ja::Music* 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(Ja::Sound* 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>(std::lround(volume * 100.0F));
|
||||
}
|
||||
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
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
#include "core/audio/audio_adapter.hpp"
|
||||
|
||||
#include "core/resources/resource_cache.hpp"
|
||||
|
||||
namespace AudioResource {
|
||||
|
||||
auto getMusic(const std::string& name) -> Ja::Music* {
|
||||
return Resource::Cache::get()->getMusic(name);
|
||||
}
|
||||
|
||||
auto getSound(const std::string& name) -> Ja::Sound* {
|
||||
return Resource::Cache::get()->getSound(name);
|
||||
}
|
||||
|
||||
} // namespace AudioResource
|
||||
@@ -0,0 +1,19 @@
|
||||
#pragma once
|
||||
|
||||
// --- Audio Resource Adapter ---
|
||||
// Aquest fitxer exposa una interfície comuna a Audio per obtenir Ja::Music* /
|
||||
// Ja::Sound* 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
|
||||
|
||||
namespace Ja {
|
||||
struct Music;
|
||||
struct Sound;
|
||||
} // namespace Ja
|
||||
|
||||
namespace AudioResource {
|
||||
auto getMusic(const std::string& name) -> Ja::Music*;
|
||||
auto getSound(const std::string& name) -> Ja::Sound*;
|
||||
} // namespace AudioResource
|
||||
@@ -0,0 +1,699 @@
|
||||
#pragma once
|
||||
|
||||
// --- Includes ---
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#define STB_VORBIS_HEADER_ONLY
|
||||
// NOLINTNEXTLINE(bugprone-suspicious-include) — stb_vorbis és single-file: la macro de dalt limita a només-declaracions.
|
||||
#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 != nullptr) { SDL_free(p); }
|
||||
}
|
||||
};
|
||||
|
||||
namespace Ja {
|
||||
|
||||
// --- Public Enums ---
|
||||
enum class ChannelState : std::uint8_t {
|
||||
INVALID,
|
||||
FREE,
|
||||
PLAYING,
|
||||
PAUSED,
|
||||
DISABLED,
|
||||
};
|
||||
|
||||
enum class MusicState : std::uint8_t {
|
||||
INVALID,
|
||||
PLAYING,
|
||||
PAUSED,
|
||||
STOPPED,
|
||||
DISABLED,
|
||||
};
|
||||
|
||||
// --- Constants ---
|
||||
inline constexpr int MAX_SIMULTANEOUS_CHANNELS = 20;
|
||||
inline constexpr int MAX_GROUPS = 2;
|
||||
inline constexpr SDL_AudioSpec DEFAULT_SPEC{.format = SDL_AUDIO_S16, .channels = 2, .freq = 48000};
|
||||
|
||||
// --- Struct Definitions ---
|
||||
struct Sound {
|
||||
SDL_AudioSpec spec{DEFAULT_SPEC};
|
||||
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;
|
||||
};
|
||||
|
||||
// L'ordre (punters primer, ints després, enum de 8 bits al final) minimitza
|
||||
// el padding a 64-bit (evita avisos de clang-analyzer-optin.performance.Padding).
|
||||
struct Channel {
|
||||
Sound* sound{nullptr};
|
||||
SDL_AudioStream* stream{nullptr};
|
||||
int pos{0};
|
||||
int times{0};
|
||||
int group{0};
|
||||
ChannelState state{ChannelState::FREE};
|
||||
};
|
||||
|
||||
struct Music {
|
||||
SDL_AudioSpec spec{DEFAULT_SPEC};
|
||||
|
||||
// OGG comprimit en memòria. Propietat nostra; es copia des del buffer
|
||||
// d'entrada una sola vegada en 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 Music
|
||||
|
||||
std::string filename;
|
||||
|
||||
int times{0}; // loops restants (-1 = infinit, 0 = un sol play)
|
||||
SDL_AudioStream* stream{nullptr};
|
||||
MusicState state{MusicState::INVALID};
|
||||
};
|
||||
|
||||
struct FadeState {
|
||||
bool active{false};
|
||||
Uint64 start_time{0};
|
||||
int duration_ms{0};
|
||||
float initial_volume{0.0F};
|
||||
};
|
||||
|
||||
struct OutgoingMusic {
|
||||
SDL_AudioStream* stream{nullptr};
|
||||
FadeState fade;
|
||||
};
|
||||
|
||||
// --- Internal Global State (inline, C++17) ---
|
||||
inline Music* current_music{nullptr};
|
||||
inline Channel channels[MAX_SIMULTANEOUS_CHANNELS];
|
||||
|
||||
inline SDL_AudioSpec audio_spec{DEFAULT_SPEC};
|
||||
inline float music_volume{1.0F};
|
||||
inline float sound_volume[MAX_GROUPS];
|
||||
inline bool music_enabled{true};
|
||||
inline bool sound_enabled{true};
|
||||
inline SDL_AudioDeviceID sdl_audio_device{0};
|
||||
|
||||
inline OutgoingMusic outgoing_music;
|
||||
inline FadeState incoming_fade;
|
||||
|
||||
// --- Forward Declarations ---
|
||||
inline void stopMusic();
|
||||
inline void stopChannel(int channel);
|
||||
inline auto playSoundOnChannel(Sound* sound, int channel, int loop = 0, int group = 0) -> int;
|
||||
inline void crossfadeMusic(Music* music, int crossfade_ms, int loop = -1);
|
||||
|
||||
// --- Music streaming internals ---
|
||||
// Bytes-per-sample per canal (sempre s16)
|
||||
inline constexpr int 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.
|
||||
inline constexpr int 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.
|
||||
inline constexpr float 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 auto feedMusicChunk(Music* music) -> int {
|
||||
if ((music == nullptr) || (music->vorbis == nullptr) || (music->stream == nullptr)) { return 0; }
|
||||
|
||||
short chunk[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,
|
||||
MUSIC_CHUNK_SHORTS);
|
||||
if (SAMPLES_PER_CHANNEL <= 0) { return 0; }
|
||||
|
||||
const int BYTES = SAMPLES_PER_CHANNEL * NUM_CHANNELS * MUSIC_BYTES_PER_SAMPLE;
|
||||
SDL_PutAudioStreamData(music->stream, chunk, BYTES);
|
||||
return SAMPLES_PER_CHANNEL;
|
||||
}
|
||||
|
||||
// Reompli l'stream fins que tinga ≥ MUSIC_LOW_WATER_SECONDS bufferats.
|
||||
// En arribar a EOF del vorbis, aplica el loop (times) o deixa drenar.
|
||||
inline void pumpMusic(Music* music) {
|
||||
if ((music == nullptr) || (music->vorbis == nullptr) || (music->stream == nullptr)) { return; }
|
||||
|
||||
const int BYTES_PER_SECOND = music->spec.freq * music->spec.channels * MUSIC_BYTES_PER_SAMPLE;
|
||||
const int LOW_WATER_BYTES = static_cast<int>(MUSIC_LOW_WATER_SECONDS * static_cast<float>(BYTES_PER_SECOND));
|
||||
|
||||
while (SDL_GetAudioStreamAvailable(music->stream) < LOW_WATER_BYTES) {
|
||||
const int DECODED = 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 preFillOutgoing(Music* music, int duration_ms) {
|
||||
if ((music == nullptr) || (music->vorbis == nullptr) || (music->stream == nullptr)) { return; }
|
||||
|
||||
const int BYTES_PER_SECOND = music->spec.freq * music->spec.channels * MUSIC_BYTES_PER_SAMPLE;
|
||||
const int NEEDED_BYTES = static_cast<int>((static_cast<std::int64_t>(duration_ms) * BYTES_PER_SECOND) / 1000);
|
||||
|
||||
while (SDL_GetAudioStreamAvailable(music->stream) < NEEDED_BYTES) {
|
||||
const int DECODED = feedMusicChunk(music);
|
||||
if (DECODED <= 0) { break; } // EOF: deixem drenar el que hi haja
|
||||
}
|
||||
}
|
||||
|
||||
// --- update() helpers ---
|
||||
inline void updateOutgoingFade() {
|
||||
if ((outgoing_music.stream == nullptr) || !outgoing_music.fade.active) { return; }
|
||||
|
||||
const Uint64 NOW = SDL_GetTicks();
|
||||
const Uint64 ELAPSED = NOW - outgoing_music.fade.start_time;
|
||||
if (ELAPSED >= static_cast<Uint64>(outgoing_music.fade.duration_ms)) {
|
||||
SDL_DestroyAudioStream(outgoing_music.stream);
|
||||
outgoing_music.stream = nullptr;
|
||||
outgoing_music.fade.active = false;
|
||||
} else {
|
||||
const float PERCENT = static_cast<float>(ELAPSED) / static_cast<float>(outgoing_music.fade.duration_ms);
|
||||
SDL_SetAudioStreamGain(outgoing_music.stream, outgoing_music.fade.initial_volume * (1.0F - PERCENT));
|
||||
}
|
||||
}
|
||||
|
||||
inline void updateIncomingFade() {
|
||||
if (!incoming_fade.active) { return; }
|
||||
|
||||
const Uint64 NOW = SDL_GetTicks();
|
||||
const Uint64 ELAPSED = NOW - incoming_fade.start_time;
|
||||
if (ELAPSED >= static_cast<Uint64>(incoming_fade.duration_ms)) {
|
||||
incoming_fade.active = false;
|
||||
SDL_SetAudioStreamGain(current_music->stream, music_volume);
|
||||
} else {
|
||||
const float PERCENT = static_cast<float>(ELAPSED) / static_cast<float>(incoming_fade.duration_ms);
|
||||
SDL_SetAudioStreamGain(current_music->stream, music_volume * PERCENT);
|
||||
}
|
||||
}
|
||||
|
||||
inline void updateCurrentMusic() {
|
||||
if (!music_enabled || (current_music == nullptr) || current_music->state != MusicState::PLAYING) { return; }
|
||||
|
||||
updateIncomingFade();
|
||||
|
||||
// Streaming: rellenem l'stream fins al low-water-mark i parem si el
|
||||
// vorbis s'ha esgotat i no queden loops.
|
||||
pumpMusic(current_music);
|
||||
if (current_music->times == 0 && SDL_GetAudioStreamAvailable(current_music->stream) == 0) {
|
||||
stopMusic();
|
||||
}
|
||||
}
|
||||
|
||||
inline void updateSoundChannels() {
|
||||
if (!sound_enabled) { return; }
|
||||
|
||||
for (int i = 0; i < MAX_SIMULTANEOUS_CHANNELS; ++i) {
|
||||
auto& ch = channels[i];
|
||||
if (ch.state != ChannelState::PLAYING) { continue; }
|
||||
|
||||
if (ch.times != 0) {
|
||||
if (static_cast<Uint32>(SDL_GetAudioStreamAvailable(ch.stream)) < (ch.sound->length / 2)) {
|
||||
SDL_PutAudioStreamData(ch.stream, ch.sound->buffer.get(), ch.sound->length);
|
||||
if (ch.times > 0) { ch.times--; }
|
||||
}
|
||||
} else {
|
||||
if (SDL_GetAudioStreamAvailable(ch.stream) == 0) { stopChannel(i); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline void update() {
|
||||
updateOutgoingFade();
|
||||
updateCurrentMusic();
|
||||
updateSoundChannels();
|
||||
}
|
||||
|
||||
inline void init(int freq, SDL_AudioFormat format, int num_channels) {
|
||||
audio_spec = {.format = format, .channels = num_channels, .freq = freq};
|
||||
if (sdl_audio_device != 0) { SDL_CloseAudioDevice(sdl_audio_device); }
|
||||
sdl_audio_device = SDL_OpenAudioDevice(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, &audio_spec);
|
||||
if (sdl_audio_device == 0) { std::cout << "Failed to initialize SDL audio!" << '\n'; }
|
||||
for (auto& ch : channels) { ch.state = ChannelState::FREE; }
|
||||
std::ranges::fill(sound_volume, 0.5F);
|
||||
}
|
||||
|
||||
inline void quit() {
|
||||
if (outgoing_music.stream != nullptr) {
|
||||
SDL_DestroyAudioStream(outgoing_music.stream);
|
||||
outgoing_music.stream = nullptr;
|
||||
}
|
||||
if (sdl_audio_device != 0) { SDL_CloseAudioDevice(sdl_audio_device); }
|
||||
sdl_audio_device = 0;
|
||||
}
|
||||
|
||||
// --- Music Functions ---
|
||||
inline auto loadMusic(const Uint8* buffer, Uint32 length) -> Music* {
|
||||
if ((buffer == nullptr) || length == 0) { return nullptr; }
|
||||
|
||||
// Allocem el Music 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 Music();
|
||||
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 == nullptr) {
|
||||
std::cout << "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 = MusicState::STOPPED;
|
||||
|
||||
return music;
|
||||
}
|
||||
|
||||
// Overload amb filename — els callers l'usen per poder comparar la música
|
||||
// en curs amb getMusicFilename() i no rearrancar-la si ja és la mateixa.
|
||||
inline auto loadMusic(Uint8* buffer, Uint32 length, const char* filename) -> Music* {
|
||||
Music* music = loadMusic(static_cast<const Uint8*>(buffer), length);
|
||||
if ((music != nullptr) && (filename != nullptr)) { music->filename = filename; }
|
||||
return music;
|
||||
}
|
||||
|
||||
inline auto loadMusic(const char* filename) -> Music* {
|
||||
// Carreguem primer el arxiu en memòria i després el descomprimim.
|
||||
FILE* f = std::fopen(filename, "rb");
|
||||
if (f == nullptr) { return nullptr; }
|
||||
std::fseek(f, 0, SEEK_END);
|
||||
const long FSIZE = std::ftell(f);
|
||||
std::fseek(f, 0, SEEK_SET);
|
||||
if (FSIZE <= 0) {
|
||||
std::fclose(f);
|
||||
return nullptr;
|
||||
}
|
||||
auto* buffer = static_cast<Uint8*>(std::malloc(static_cast<size_t>(FSIZE) + 1));
|
||||
if (buffer == nullptr) {
|
||||
std::fclose(f);
|
||||
return nullptr;
|
||||
}
|
||||
if (std::fread(buffer, FSIZE, 1, f) != 1) {
|
||||
std::fclose(f);
|
||||
std::free(buffer);
|
||||
return nullptr;
|
||||
}
|
||||
std::fclose(f);
|
||||
|
||||
Music* music = loadMusic(static_cast<const Uint8*>(buffer), static_cast<Uint32>(FSIZE));
|
||||
if (music != nullptr) { music->filename = filename; }
|
||||
|
||||
std::free(buffer);
|
||||
|
||||
return music;
|
||||
}
|
||||
|
||||
inline void playMusic(Music* music, int loop = -1) {
|
||||
if (!music_enabled || (music == nullptr) || (music->vorbis == nullptr)) { return; }
|
||||
|
||||
stopMusic();
|
||||
|
||||
current_music = music;
|
||||
current_music->state = MusicState::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(¤t_music->spec, &audio_spec);
|
||||
if (current_music->stream == nullptr) {
|
||||
std::cout << "Failed to create audio stream!" << '\n';
|
||||
current_music->state = MusicState::STOPPED;
|
||||
return;
|
||||
}
|
||||
SDL_SetAudioStreamGain(current_music->stream, music_volume);
|
||||
|
||||
// Pre-cargem el buffer abans de bindejar per evitar un underrun inicial.
|
||||
pumpMusic(current_music);
|
||||
|
||||
if (!SDL_BindAudioStream(sdl_audio_device, current_music->stream)) {
|
||||
std::cout << "[ERROR] SDL_BindAudioStream failed!" << '\n';
|
||||
}
|
||||
}
|
||||
|
||||
inline auto getMusicFilename(const Music* music = nullptr) -> const char* {
|
||||
if (music == nullptr) { music = current_music; }
|
||||
if ((music == nullptr) || music->filename.empty()) { return nullptr; }
|
||||
return music->filename.c_str();
|
||||
}
|
||||
|
||||
inline void pauseMusic() {
|
||||
if (!music_enabled) { return; }
|
||||
if ((current_music == nullptr) || current_music->state != MusicState::PLAYING) { return; }
|
||||
|
||||
current_music->state = MusicState::PAUSED;
|
||||
SDL_UnbindAudioStream(current_music->stream);
|
||||
}
|
||||
|
||||
inline void resumeMusic() {
|
||||
if (!music_enabled) { return; }
|
||||
if ((current_music == nullptr) || current_music->state != MusicState::PAUSED) { return; }
|
||||
|
||||
current_music->state = MusicState::PLAYING;
|
||||
SDL_BindAudioStream(sdl_audio_device, current_music->stream);
|
||||
}
|
||||
|
||||
inline void stopMusic() {
|
||||
// Limpiar outgoing crossfade si existe
|
||||
if (outgoing_music.stream != nullptr) {
|
||||
SDL_DestroyAudioStream(outgoing_music.stream);
|
||||
outgoing_music.stream = nullptr;
|
||||
outgoing_music.fade.active = false;
|
||||
}
|
||||
incoming_fade.active = false;
|
||||
|
||||
if ((current_music == nullptr) || current_music->state == MusicState::INVALID || current_music->state == MusicState::STOPPED) { return; }
|
||||
|
||||
current_music->state = MusicState::STOPPED;
|
||||
if (current_music->stream != nullptr) {
|
||||
SDL_DestroyAudioStream(current_music->stream);
|
||||
current_music->stream = nullptr;
|
||||
}
|
||||
// Deixem el handle de vorbis viu — es tanca en deleteMusic.
|
||||
// Rebobinem perquè un futur playMusic comence des del principi.
|
||||
if (current_music->vorbis != nullptr) {
|
||||
stb_vorbis_seek_start(current_music->vorbis);
|
||||
}
|
||||
}
|
||||
|
||||
inline void fadeOutMusic(int milliseconds) {
|
||||
if (!music_enabled) { return; }
|
||||
if ((current_music == nullptr) || current_music->state != MusicState::PLAYING) { return; }
|
||||
|
||||
// Destruir outgoing anterior si existe
|
||||
if (outgoing_music.stream != nullptr) {
|
||||
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.
|
||||
preFillOutgoing(current_music, milliseconds);
|
||||
|
||||
// Robar el stream del current_music al outgoing
|
||||
outgoing_music.stream = current_music->stream;
|
||||
outgoing_music.fade = {.active = true, .start_time = SDL_GetTicks(), .duration_ms = milliseconds, .initial_volume = music_volume};
|
||||
|
||||
// Dejar current_music sin stream (ya lo tiene outgoing)
|
||||
current_music->stream = nullptr;
|
||||
current_music->state = MusicState::STOPPED;
|
||||
if (current_music->vorbis != nullptr) { stb_vorbis_seek_start(current_music->vorbis); }
|
||||
incoming_fade.active = false;
|
||||
}
|
||||
|
||||
inline void crossfadeMusic(Music* music, int crossfade_ms, int loop) {
|
||||
if (!music_enabled || (music == nullptr) || (music->vorbis == nullptr)) { return; }
|
||||
|
||||
// Destruir outgoing anterior si existe (crossfade durante crossfade)
|
||||
if (outgoing_music.stream != nullptr) {
|
||||
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 != nullptr) && current_music->state == MusicState::PLAYING && (current_music->stream != nullptr)) {
|
||||
preFillOutgoing(current_music, crossfade_ms);
|
||||
outgoing_music.stream = current_music->stream;
|
||||
outgoing_music.fade = {.active = true, .start_time = SDL_GetTicks(), .duration_ms = crossfade_ms, .initial_volume = music_volume};
|
||||
current_music->stream = nullptr;
|
||||
current_music->state = MusicState::STOPPED;
|
||||
if (current_music->vorbis != nullptr) { 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 = MusicState::PLAYING;
|
||||
current_music->times = loop;
|
||||
|
||||
stb_vorbis_seek_start(current_music->vorbis);
|
||||
current_music->stream = SDL_CreateAudioStream(¤t_music->spec, &audio_spec);
|
||||
if (current_music->stream == nullptr) {
|
||||
std::cout << "Failed to create audio stream for crossfade!" << '\n';
|
||||
current_music->state = MusicState::STOPPED;
|
||||
return;
|
||||
}
|
||||
SDL_SetAudioStreamGain(current_music->stream, 0.0F);
|
||||
pumpMusic(current_music); // pre-carrega abans de bindejar
|
||||
SDL_BindAudioStream(sdl_audio_device, current_music->stream);
|
||||
|
||||
// Configurar fade-in
|
||||
incoming_fade = {.active = true, .start_time = SDL_GetTicks(), .duration_ms = crossfade_ms, .initial_volume = 0.0F};
|
||||
}
|
||||
|
||||
inline auto getMusicState() -> MusicState {
|
||||
if (!music_enabled) { return MusicState::DISABLED; }
|
||||
if (current_music == nullptr) { return MusicState::INVALID; }
|
||||
|
||||
return current_music->state;
|
||||
}
|
||||
|
||||
inline void deleteMusic(Music* music) {
|
||||
if (music == nullptr) { return; }
|
||||
if (current_music == music) {
|
||||
stopMusic();
|
||||
current_music = nullptr;
|
||||
}
|
||||
if (music->stream != nullptr) { SDL_DestroyAudioStream(music->stream); }
|
||||
if (music->vorbis != nullptr) { stb_vorbis_close(music->vorbis); }
|
||||
// ogg_data (std::vector) i filename (std::string) s'alliberen sols
|
||||
// al destructor de Music.
|
||||
delete music;
|
||||
}
|
||||
|
||||
inline auto setMusicVolume(float volume) -> float {
|
||||
music_volume = SDL_clamp(volume, 0.0F, 1.0F);
|
||||
if ((current_music != nullptr) && (current_music->stream != nullptr)) {
|
||||
SDL_SetAudioStreamGain(current_music->stream, music_volume);
|
||||
}
|
||||
return music_volume;
|
||||
}
|
||||
|
||||
inline void setMusicPosition(float /*value*/) {
|
||||
// No implementat amb el backend de streaming.
|
||||
}
|
||||
|
||||
inline auto getMusicPosition() -> float {
|
||||
return 0.0F;
|
||||
}
|
||||
|
||||
inline void enableMusic(bool value) {
|
||||
if (!value && (current_music != nullptr) && (current_music->state == MusicState::PLAYING)) { stopMusic(); }
|
||||
music_enabled = value;
|
||||
}
|
||||
|
||||
// --- Sound Functions ---
|
||||
inline auto loadSound(std::uint8_t* buffer, std::uint32_t size) -> Sound* {
|
||||
auto sound = std::make_unique<Sound>();
|
||||
Uint8* raw = nullptr;
|
||||
if (!SDL_LoadWAV_IO(SDL_IOFromMem(buffer, size), true, &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 auto loadSound(const char* filename) -> Sound* {
|
||||
auto sound = std::make_unique<Sound>();
|
||||
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 auto playSound(Sound* sound, int loop = 0, int group = 0) -> int {
|
||||
if (!sound_enabled || (sound == nullptr)) { return -1; }
|
||||
|
||||
int channel = 0;
|
||||
while (channel < MAX_SIMULTANEOUS_CHANNELS && channels[channel].state != ChannelState::FREE) { channel++; }
|
||||
if (channel == MAX_SIMULTANEOUS_CHANNELS) {
|
||||
// No hi ha canal lliure, reemplacem el primer
|
||||
channel = 0;
|
||||
}
|
||||
|
||||
return playSoundOnChannel(sound, channel, loop, group);
|
||||
}
|
||||
|
||||
inline auto playSoundOnChannel(Sound* sound, int channel, int loop, int group) -> int {
|
||||
if (!sound_enabled || (sound == nullptr)) { return -1; }
|
||||
if (channel < 0 || channel >= MAX_SIMULTANEOUS_CHANNELS) { return -1; }
|
||||
|
||||
stopChannel(channel);
|
||||
|
||||
channels[channel].sound = sound;
|
||||
channels[channel].times = loop;
|
||||
channels[channel].pos = 0;
|
||||
channels[channel].group = group;
|
||||
channels[channel].state = ChannelState::PLAYING;
|
||||
channels[channel].stream = SDL_CreateAudioStream(&channels[channel].sound->spec, &audio_spec);
|
||||
|
||||
if (channels[channel].stream == nullptr) {
|
||||
std::cout << "Failed to create audio stream for sound!" << '\n';
|
||||
channels[channel].state = ChannelState::FREE;
|
||||
return -1;
|
||||
}
|
||||
|
||||
SDL_PutAudioStreamData(channels[channel].stream, channels[channel].sound->buffer.get(), channels[channel].sound->length);
|
||||
SDL_SetAudioStreamGain(channels[channel].stream, sound_volume[group]);
|
||||
SDL_BindAudioStream(sdl_audio_device, channels[channel].stream);
|
||||
|
||||
return channel;
|
||||
}
|
||||
|
||||
inline void deleteSound(Sound* sound) {
|
||||
if (sound == nullptr) { return; }
|
||||
for (int i = 0; i < MAX_SIMULTANEOUS_CHANNELS; i++) {
|
||||
if (channels[i].sound == sound) { stopChannel(i); }
|
||||
}
|
||||
// buffer es destrueix automàticament via RAII (SdlFreeDeleter).
|
||||
delete sound;
|
||||
}
|
||||
|
||||
inline void pauseChannel(int channel) {
|
||||
if (!sound_enabled) { return; }
|
||||
|
||||
if (channel == -1) {
|
||||
for (auto& ch : channels) {
|
||||
if (ch.state == ChannelState::PLAYING) {
|
||||
ch.state = ChannelState::PAUSED;
|
||||
SDL_UnbindAudioStream(ch.stream);
|
||||
}
|
||||
}
|
||||
} else if (channel >= 0 && channel < MAX_SIMULTANEOUS_CHANNELS) {
|
||||
if (channels[channel].state == ChannelState::PLAYING) {
|
||||
channels[channel].state = ChannelState::PAUSED;
|
||||
SDL_UnbindAudioStream(channels[channel].stream);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline void resumeChannel(int channel) {
|
||||
if (!sound_enabled) { return; }
|
||||
|
||||
if (channel == -1) {
|
||||
for (auto& ch : channels) {
|
||||
if (ch.state == ChannelState::PAUSED) {
|
||||
ch.state = ChannelState::PLAYING;
|
||||
SDL_BindAudioStream(sdl_audio_device, ch.stream);
|
||||
}
|
||||
}
|
||||
} else if (channel >= 0 && channel < MAX_SIMULTANEOUS_CHANNELS) {
|
||||
if (channels[channel].state == ChannelState::PAUSED) {
|
||||
channels[channel].state = ChannelState::PLAYING;
|
||||
SDL_BindAudioStream(sdl_audio_device, channels[channel].stream);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline void stopChannel(int channel) {
|
||||
if (channel == -1) {
|
||||
for (auto& ch : channels) {
|
||||
if (ch.state != ChannelState::FREE) {
|
||||
if (ch.stream != nullptr) { SDL_DestroyAudioStream(ch.stream); }
|
||||
ch.stream = nullptr;
|
||||
ch.state = ChannelState::FREE;
|
||||
ch.pos = 0;
|
||||
ch.sound = nullptr;
|
||||
}
|
||||
}
|
||||
} else if (channel >= 0 && channel < MAX_SIMULTANEOUS_CHANNELS) {
|
||||
if (channels[channel].state != ChannelState::FREE) {
|
||||
if (channels[channel].stream != nullptr) { SDL_DestroyAudioStream(channels[channel].stream); }
|
||||
channels[channel].stream = nullptr;
|
||||
channels[channel].state = ChannelState::FREE;
|
||||
channels[channel].pos = 0;
|
||||
channels[channel].sound = nullptr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline auto getChannelState(int channel) -> ChannelState {
|
||||
if (!sound_enabled) { return ChannelState::DISABLED; }
|
||||
if (channel < 0 || channel >= MAX_SIMULTANEOUS_CHANNELS) { return ChannelState::INVALID; }
|
||||
|
||||
return channels[channel].state;
|
||||
}
|
||||
|
||||
inline auto setSoundVolume(float volume, int group = -1) -> float {
|
||||
const float V = SDL_clamp(volume, 0.0F, 1.0F);
|
||||
|
||||
if (group == -1) {
|
||||
std::ranges::fill(sound_volume, V);
|
||||
} else if (group >= 0 && group < MAX_GROUPS) {
|
||||
sound_volume[group] = V;
|
||||
} else {
|
||||
return V;
|
||||
}
|
||||
|
||||
// Aplicar volum als canals actius.
|
||||
for (auto& ch : channels) {
|
||||
if ((ch.state == ChannelState::PLAYING) || (ch.state == ChannelState::PAUSED)) {
|
||||
if (group == -1 || ch.group == group) {
|
||||
if (ch.stream != nullptr) {
|
||||
SDL_SetAudioStreamGain(ch.stream, sound_volume[ch.group]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return V;
|
||||
}
|
||||
|
||||
inline void enableSound(bool value) {
|
||||
if (!value) {
|
||||
stopChannel(-1);
|
||||
}
|
||||
sound_enabled = value;
|
||||
}
|
||||
|
||||
inline auto setVolume(float volume) -> float {
|
||||
const float V = setMusicVolume(volume);
|
||||
setSoundVolume(V, -1);
|
||||
return V;
|
||||
}
|
||||
|
||||
} // namespace Ja
|
||||
@@ -1,40 +1,120 @@
|
||||
#include "core/input/gamepad.hpp"
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
#include "core/input/key_config.hpp"
|
||||
#include "core/jail/jinput.hpp"
|
||||
#include "core/locale/locale.hpp"
|
||||
#include "core/rendering/menu.hpp"
|
||||
#include "game/options.hpp"
|
||||
#include "core/rendering/overlay.hpp"
|
||||
|
||||
namespace Gamepad {
|
||||
|
||||
static SDL_Gamepad* pad_ = nullptr;
|
||||
static SDL_JoystickID pad_id_ = 0;
|
||||
static SDL_Gamepad* pad = nullptr;
|
||||
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 auto prettyName(const char* raw) -> std::string {
|
||||
std::string name = ((raw != nullptr) && (*raw != 0)) ? 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)
|
||||
static constexpr Sint16 STICK_DEADZONE = 12000;
|
||||
|
||||
// Estat previ per a detecció de flanc (edge-triggered)
|
||||
static bool prev_up_ = false;
|
||||
static bool prev_down_ = false;
|
||||
static bool prev_left_ = false;
|
||||
static bool prev_right_ = false;
|
||||
static bool prev_a_ = false;
|
||||
static bool prev_b_ = false;
|
||||
static bool prev_start_ = false;
|
||||
static bool prev_back_ = false;
|
||||
static bool prev_up = false;
|
||||
static bool prev_down = false;
|
||||
static bool prev_left = false;
|
||||
static bool prev_right = false;
|
||||
static bool prev_south = false;
|
||||
static bool prev_east = false;
|
||||
static bool prev_west = false;
|
||||
static bool prev_north = false;
|
||||
static bool prev_start = 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() {
|
||||
int count = 0;
|
||||
SDL_JoystickID* ids = SDL_GetGamepads(&count);
|
||||
if (ids && count > 0) {
|
||||
pad_ = SDL_OpenGamepad(ids[0]);
|
||||
if (pad_) {
|
||||
pad_id_ = ids[0];
|
||||
SDL_Log("Gamepad connectat: %s", SDL_GetGamepadName(pad_));
|
||||
SDL_JoystickID* ids = SDL_GetJoysticks(&count);
|
||||
if (ids != nullptr) {
|
||||
for (int i = 0; i < count; ++i) {
|
||||
installWebStandardMapping(ids[i]);
|
||||
if (!SDL_IsGamepad(ids[i])) {
|
||||
continue;
|
||||
}
|
||||
pad = SDL_OpenGamepad(ids[i]);
|
||||
if (pad != nullptr) {
|
||||
pad_id = ids[i];
|
||||
SDL_Log("Gamepad connectat: %s", SDL_GetGamepadName(pad));
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ids) SDL_free(ids);
|
||||
SDL_free(ids);
|
||||
}
|
||||
}
|
||||
|
||||
void init() {
|
||||
@@ -52,38 +132,50 @@ namespace Gamepad {
|
||||
}
|
||||
|
||||
void destroy() {
|
||||
if (pad_) {
|
||||
SDL_CloseGamepad(pad_);
|
||||
pad_ = nullptr;
|
||||
pad_id_ = 0;
|
||||
if (pad != nullptr) {
|
||||
SDL_CloseGamepad(pad);
|
||||
pad = nullptr;
|
||||
pad_id = 0;
|
||||
}
|
||||
SDL_QuitSubSystem(SDL_INIT_GAMEPAD);
|
||||
}
|
||||
|
||||
auto isConnected() -> bool {
|
||||
return pad_ != nullptr;
|
||||
return pad != nullptr;
|
||||
}
|
||||
|
||||
void handleEvent(const SDL_Event& event) {
|
||||
if (event.type == SDL_EVENT_GAMEPAD_ADDED) {
|
||||
if (!pad_) {
|
||||
pad_ = SDL_OpenGamepad(event.gdevice.which);
|
||||
if (pad_) {
|
||||
pad_id_ = event.gdevice.which;
|
||||
SDL_Log("Gamepad connectat: %s", SDL_GetGamepadName(pad_));
|
||||
// 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 == nullptr) {
|
||||
SDL_JoystickID jid = event.jdevice.which;
|
||||
installWebStandardMapping(jid);
|
||||
if (!SDL_IsGamepad(jid)) {
|
||||
return;
|
||||
}
|
||||
pad = SDL_OpenGamepad(jid);
|
||||
if (pad != nullptr) {
|
||||
pad_id = jid;
|
||||
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) {
|
||||
if (pad_ && event.gdevice.which == pad_id_) {
|
||||
SDL_Log("Gamepad desconnectat");
|
||||
SDL_CloseGamepad(pad_);
|
||||
pad_ = nullptr;
|
||||
pad_id_ = 0;
|
||||
} else if (event.type == SDL_EVENT_GAMEPAD_REMOVED || event.type == SDL_EVENT_JOYSTICK_REMOVED) {
|
||||
if ((pad != nullptr) && event.jdevice.which == pad_id) {
|
||||
std::string saved_name = prettyName(SDL_GetGamepadName(pad));
|
||||
SDL_Log("Gamepad desconnectat: %s", saved_name.c_str());
|
||||
SDL_CloseGamepad(pad);
|
||||
pad = nullptr;
|
||||
pad_id = 0;
|
||||
// Neteja qualsevol tecla virtual que poguera estar premuda
|
||||
JI_SetVirtualKey(SDL_SCANCODE_UP, 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_RIGHT, JI_VSRC_GAMEPAD, false);
|
||||
Ji::setVirtualKey(SDL_SCANCODE_UP, Ji::VirtualSource::GAMEPAD, false);
|
||||
Ji::setVirtualKey(SDL_SCANCODE_DOWN, Ji::VirtualSource::GAMEPAD, false);
|
||||
Ji::setVirtualKey(SDL_SCANCODE_LEFT, Ji::VirtualSource::GAMEPAD, false);
|
||||
Ji::setVirtualKey(SDL_SCANCODE_RIGHT, Ji::VirtualSource::GAMEPAD, false);
|
||||
notifyDisconnected(saved_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,71 +195,86 @@ namespace Gamepad {
|
||||
SDL_PushEvent(&e);
|
||||
}
|
||||
|
||||
void update() {
|
||||
if (!pad_) return;
|
||||
// Estat agregat d'un frame: D-pad i stick combinats, més botons frontals.
|
||||
struct PadState {
|
||||
bool up;
|
||||
bool down;
|
||||
bool left;
|
||||
bool right;
|
||||
bool south;
|
||||
bool east;
|
||||
bool west;
|
||||
bool north;
|
||||
bool start;
|
||||
bool back;
|
||||
};
|
||||
|
||||
// D-pad
|
||||
bool dup = SDL_GetGamepadButton(pad_, SDL_GAMEPAD_BUTTON_DPAD_UP);
|
||||
bool ddn = SDL_GetGamepadButton(pad_, SDL_GAMEPAD_BUTTON_DPAD_DOWN);
|
||||
bool dlt = SDL_GetGamepadButton(pad_, SDL_GAMEPAD_BUTTON_DPAD_LEFT);
|
||||
bool drt = SDL_GetGamepadButton(pad_, SDL_GAMEPAD_BUTTON_DPAD_RIGHT);
|
||||
|
||||
// Stick esquerre amb dead-zone
|
||||
Sint16 lx = SDL_GetGamepadAxis(pad_, SDL_GAMEPAD_AXIS_LEFTX);
|
||||
Sint16 ly = SDL_GetGamepadAxis(pad_, SDL_GAMEPAD_AXIS_LEFTY);
|
||||
bool sup = ly < -STICK_DEADZONE;
|
||||
bool sdn = ly > STICK_DEADZONE;
|
||||
bool slt = lx < -STICK_DEADZONE;
|
||||
bool srt = lx > STICK_DEADZONE;
|
||||
|
||||
bool up = dup || sup;
|
||||
bool dn = ddn || sdn;
|
||||
bool lt = dlt || slt;
|
||||
bool rt = drt || srt;
|
||||
|
||||
// Botons
|
||||
bool a = SDL_GetGamepadButton(pad_, SDL_GAMEPAD_BUTTON_SOUTH); // A/Cross
|
||||
bool b = SDL_GetGamepadButton(pad_, SDL_GAMEPAD_BUTTON_EAST); // B/Circle
|
||||
bool start = SDL_GetGamepadButton(pad_, SDL_GAMEPAD_BUTTON_START);
|
||||
bool back = SDL_GetGamepadButton(pad_, SDL_GAMEPAD_BUTTON_BACK);
|
||||
|
||||
// Start → obre/tanca menú (flanc)
|
||||
if (start && !prev_start_) pushKey(Options::keys_gui.menu_toggle);
|
||||
// Back → ESC (flanc)
|
||||
if (back && !prev_back_) pushKey(SDL_SCANCODE_ESCAPE);
|
||||
|
||||
if (Menu::isOpen()) {
|
||||
// Navegació del menú per flanc
|
||||
if (up && !prev_up_) pushKey(SDL_SCANCODE_UP);
|
||||
if (dn && !prev_down_) pushKey(SDL_SCANCODE_DOWN);
|
||||
if (lt && !prev_left_) pushKey(SDL_SCANCODE_LEFT);
|
||||
if (rt && !prev_right_) pushKey(SDL_SCANCODE_RIGHT);
|
||||
if (a && !prev_a_) pushKey(SDL_SCANCODE_RETURN);
|
||||
if (b && !prev_b_) pushKey(SDL_SCANCODE_BACKSPACE);
|
||||
|
||||
// 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_DOWN, JI_VSRC_GAMEPAD, false);
|
||||
JI_SetVirtualKey(SDL_SCANCODE_LEFT, JI_VSRC_GAMEPAD, false);
|
||||
JI_SetVirtualKey(SDL_SCANCODE_RIGHT, JI_VSRC_GAMEPAD, false);
|
||||
} else {
|
||||
// Moviment al joc — level-triggered (polling)
|
||||
JI_SetVirtualKey(SDL_SCANCODE_UP, JI_VSRC_GAMEPAD, up);
|
||||
JI_SetVirtualKey(SDL_SCANCODE_DOWN, JI_VSRC_GAMEPAD, dn);
|
||||
JI_SetVirtualKey(SDL_SCANCODE_LEFT, JI_VSRC_GAMEPAD, lt);
|
||||
JI_SetVirtualKey(SDL_SCANCODE_RIGHT, JI_VSRC_GAMEPAD, rt);
|
||||
// Botó A al joc: emet Enter per avançar seqüències (JI_AnyKey)
|
||||
if (a && !prev_a_) pushKey(SDL_SCANCODE_RETURN);
|
||||
static auto readPadState() -> PadState {
|
||||
const Sint16 LX = SDL_GetGamepadAxis(pad, SDL_GAMEPAD_AXIS_LEFTX);
|
||||
const Sint16 LY = SDL_GetGamepadAxis(pad, SDL_GAMEPAD_AXIS_LEFTY);
|
||||
return PadState{
|
||||
.up = SDL_GetGamepadButton(pad, SDL_GAMEPAD_BUTTON_DPAD_UP) || LY < -STICK_DEADZONE,
|
||||
.down = SDL_GetGamepadButton(pad, SDL_GAMEPAD_BUTTON_DPAD_DOWN) || LY > STICK_DEADZONE,
|
||||
.left = SDL_GetGamepadButton(pad, SDL_GAMEPAD_BUTTON_DPAD_LEFT) || LX < -STICK_DEADZONE,
|
||||
.right = SDL_GetGamepadButton(pad, SDL_GAMEPAD_BUTTON_DPAD_RIGHT) || LX > STICK_DEADZONE,
|
||||
.south = SDL_GetGamepadButton(pad, SDL_GAMEPAD_BUTTON_SOUTH),
|
||||
.east = SDL_GetGamepadButton(pad, SDL_GAMEPAD_BUTTON_EAST),
|
||||
.west = SDL_GetGamepadButton(pad, SDL_GAMEPAD_BUTTON_WEST),
|
||||
.north = SDL_GetGamepadButton(pad, SDL_GAMEPAD_BUTTON_NORTH),
|
||||
.start = SDL_GetGamepadButton(pad, SDL_GAMEPAD_BUTTON_START),
|
||||
.back = SDL_GetGamepadButton(pad, SDL_GAMEPAD_BUTTON_BACK),
|
||||
};
|
||||
}
|
||||
|
||||
prev_up_ = up;
|
||||
prev_down_ = dn;
|
||||
prev_left_ = lt;
|
||||
prev_right_ = rt;
|
||||
prev_a_ = a;
|
||||
prev_b_ = b;
|
||||
prev_start_ = start;
|
||||
prev_back_ = back;
|
||||
static void handleMenuNavigation(const PadState& s) {
|
||||
if (s.up && !prev_up) { pushKey(SDL_SCANCODE_UP); }
|
||||
if (s.down && !prev_down) { pushKey(SDL_SCANCODE_DOWN); }
|
||||
if (s.left && !prev_left) { pushKey(SDL_SCANCODE_LEFT); }
|
||||
if (s.right && !prev_right) { pushKey(SDL_SCANCODE_RIGHT); }
|
||||
if (s.east && !prev_east) { pushKey(SDL_SCANCODE_RETURN); }
|
||||
if (s.south && !prev_south) { pushKey(SDL_SCANCODE_BACKSPACE); }
|
||||
// Mentre el menú està obert, el joc no ha de rebre moviment.
|
||||
Ji::setVirtualKey(SDL_SCANCODE_UP, Ji::VirtualSource::GAMEPAD, false);
|
||||
Ji::setVirtualKey(SDL_SCANCODE_DOWN, Ji::VirtualSource::GAMEPAD, false);
|
||||
Ji::setVirtualKey(SDL_SCANCODE_LEFT, Ji::VirtualSource::GAMEPAD, false);
|
||||
Ji::setVirtualKey(SDL_SCANCODE_RIGHT, Ji::VirtualSource::GAMEPAD, false);
|
||||
}
|
||||
|
||||
static void handleGameInput(const PadState& s) {
|
||||
Ji::setVirtualKey(SDL_SCANCODE_UP, Ji::VirtualSource::GAMEPAD, s.up);
|
||||
Ji::setVirtualKey(SDL_SCANCODE_DOWN, Ji::VirtualSource::GAMEPAD, s.down);
|
||||
Ji::setVirtualKey(SDL_SCANCODE_LEFT, Ji::VirtualSource::GAMEPAD, s.left);
|
||||
Ji::setVirtualKey(SDL_SCANCODE_RIGHT, Ji::VirtualSource::GAMEPAD, s.right);
|
||||
const bool ANY_FRONT_EDGE = (s.south && !prev_south) || (s.east && !prev_east) ||
|
||||
(s.west && !prev_west) || (s.north && !prev_north);
|
||||
if (ANY_FRONT_EDGE) {
|
||||
pushKey(SDL_SCANCODE_RETURN);
|
||||
}
|
||||
}
|
||||
|
||||
void update() {
|
||||
if (pad == nullptr) {
|
||||
return;
|
||||
}
|
||||
const PadState S = readPadState();
|
||||
// Flancs globals: Select i Start sempre operen.
|
||||
if (S.back && !prev_back) { pushKey(KeyConfig::scancode("menu_toggle")); }
|
||||
if (S.start && !prev_start) { pushKey(KeyConfig::scancode("pause_toggle")); }
|
||||
if (Menu::isOpen()) {
|
||||
handleMenuNavigation(S);
|
||||
} else {
|
||||
handleGameInput(S);
|
||||
}
|
||||
prev_up = S.up;
|
||||
prev_down = S.down;
|
||||
prev_left = S.left;
|
||||
prev_right = S.right;
|
||||
prev_south = S.south;
|
||||
prev_east = S.east;
|
||||
prev_west = S.west;
|
||||
prev_north = S.north;
|
||||
prev_start = S.start;
|
||||
prev_back = S.back;
|
||||
}
|
||||
|
||||
} // namespace Gamepad
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
#include "core/input/global_inputs.hpp"
|
||||
|
||||
#include <cstdio>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
#include "core/input/key_config.hpp"
|
||||
#include "core/jail/jinput.hpp"
|
||||
#include "core/locale/locale.hpp"
|
||||
#include "core/rendering/overlay.hpp"
|
||||
@@ -16,112 +18,71 @@ namespace GlobalInputs {
|
||||
static bool fullscreen_prev = false;
|
||||
static bool shader_prev = false;
|
||||
static bool aspect_prev = false;
|
||||
static bool ss_prev = false;
|
||||
static bool next_shader_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;
|
||||
|
||||
// Patró comú: lectura amb detecció de flanc + acumulació al flag "consumed".
|
||||
// `on_press` només s'executa al flanc puja; `prev` es manté actualitzat.
|
||||
static auto edgeTrigger(const char* key_id, bool& prev, const std::function<void()>& on_press) -> bool {
|
||||
const bool PRESSED = Ji::keyPressed(KeyConfig::scancode(key_id));
|
||||
if (PRESSED && !prev) {
|
||||
on_press();
|
||||
}
|
||||
prev = PRESSED;
|
||||
return PRESSED;
|
||||
}
|
||||
|
||||
auto handle() -> bool {
|
||||
bool consumed = false;
|
||||
|
||||
// F1 — Reduir zoom
|
||||
bool dec_zoom = JI_KeyPressed(Options::keys_gui.dec_zoom);
|
||||
if (dec_zoom && !dec_zoom_prev) {
|
||||
consumed |= edgeTrigger("dec_zoom", dec_zoom_prev, [] {
|
||||
Screen::get()->decZoom();
|
||||
char msg[32];
|
||||
snprintf(msg, sizeof(msg), Locale::get("notifications.zoom_fmt"), Screen::get()->getZoom());
|
||||
Overlay::showNotification(msg);
|
||||
}
|
||||
if (dec_zoom) consumed = true;
|
||||
dec_zoom_prev = dec_zoom;
|
||||
|
||||
// F2 — Augmentar zoom
|
||||
bool inc_zoom = JI_KeyPressed(Options::keys_gui.inc_zoom);
|
||||
if (inc_zoom && !inc_zoom_prev) {
|
||||
});
|
||||
consumed |= edgeTrigger("inc_zoom", inc_zoom_prev, [] {
|
||||
Screen::get()->incZoom();
|
||||
char msg[32];
|
||||
snprintf(msg, sizeof(msg), Locale::get("notifications.zoom_fmt"), Screen::get()->getZoom());
|
||||
Overlay::showNotification(msg);
|
||||
}
|
||||
if (inc_zoom) consumed = true;
|
||||
inc_zoom_prev = inc_zoom;
|
||||
|
||||
// F3 — Toggle pantalla completa
|
||||
bool fullscreen = JI_KeyPressed(Options::keys_gui.fullscreen);
|
||||
if (fullscreen && !fullscreen_prev) {
|
||||
});
|
||||
consumed |= edgeTrigger("fullscreen", fullscreen_prev, [] {
|
||||
Screen::get()->toggleFullscreen();
|
||||
Overlay::showNotification(Screen::get()->isFullscreen() ? Locale::get("notifications.fullscreen") : Locale::get("notifications.windowed"));
|
||||
}
|
||||
if (fullscreen) consumed = true;
|
||||
fullscreen_prev = fullscreen;
|
||||
|
||||
// F4 — Toggle shaders
|
||||
bool shader = JI_KeyPressed(Options::keys_gui.toggle_shader);
|
||||
if (shader && !shader_prev) {
|
||||
});
|
||||
consumed |= edgeTrigger("toggle_shader", shader_prev, [] {
|
||||
Screen::get()->toggleShaders();
|
||||
Overlay::showNotification(Options::video.shader_enabled ? Locale::get("notifications.shader_on") : Locale::get("notifications.shader_off"));
|
||||
}
|
||||
if (shader) consumed = true;
|
||||
shader_prev = shader;
|
||||
|
||||
// F5 — Toggle aspect ratio 4:3
|
||||
bool aspect = JI_KeyPressed(Options::keys_gui.toggle_aspect_ratio);
|
||||
if (aspect && !aspect_prev) {
|
||||
});
|
||||
consumed |= edgeTrigger("toggle_aspect_ratio", aspect_prev, [] {
|
||||
Screen::get()->toggleAspectRatio();
|
||||
Overlay::showNotification(Options::video.aspect_ratio_4_3 ? Locale::get("notifications.aspect_43") : Locale::get("notifications.aspect_square"));
|
||||
}
|
||||
if (aspect) consumed = true;
|
||||
aspect_prev = aspect;
|
||||
|
||||
// F6 — Toggle supersampling
|
||||
bool ss = JI_KeyPressed(Options::keys_gui.toggle_supersampling);
|
||||
if (ss && !ss_prev) {
|
||||
Screen::get()->toggleSupersampling();
|
||||
Overlay::showNotification(Options::video.supersampling ? Locale::get("notifications.ss_on") : Locale::get("notifications.ss_off"));
|
||||
}
|
||||
if (ss) consumed = true;
|
||||
ss_prev = ss;
|
||||
|
||||
// F7 — Canviar tipus de shader (PostFX ↔ CrtPi)
|
||||
bool next_shader = JI_KeyPressed(Options::keys_gui.next_shader);
|
||||
if (next_shader && !next_shader_prev) {
|
||||
Screen::get()->nextShaderType();
|
||||
});
|
||||
consumed |= edgeTrigger("next_shader", next_shader_prev, [] {
|
||||
if (Screen::get()->nextShaderType()) {
|
||||
char msg[64];
|
||||
snprintf(msg, sizeof(msg), "%s: %s", Screen::get()->getActiveShaderName(), Screen::get()->getCurrentPresetName());
|
||||
Overlay::showNotification(msg);
|
||||
}
|
||||
if (next_shader) consumed = true;
|
||||
next_shader_prev = next_shader;
|
||||
|
||||
// F8 — Pròxim preset del shader actiu
|
||||
bool next_preset = JI_KeyPressed(Options::keys_gui.next_shader_preset);
|
||||
if (next_preset && !next_preset_prev) {
|
||||
Screen::get()->nextPreset();
|
||||
});
|
||||
consumed |= edgeTrigger("next_shader_preset", next_preset_prev, [] {
|
||||
if (Screen::get()->nextPreset()) {
|
||||
char msg[64];
|
||||
snprintf(msg, sizeof(msg), Locale::get("notifications.preset_fmt"), Screen::get()->getCurrentPresetName());
|
||||
Overlay::showNotification(msg);
|
||||
}
|
||||
if (next_preset) consumed = true;
|
||||
next_preset_prev = next_preset;
|
||||
|
||||
// F9 — Toggle filtre d'estirament 4:3 (NEAREST ↔ LINEAR)
|
||||
bool stretch_filter = JI_KeyPressed(Options::keys_gui.toggle_stretch_filter);
|
||||
if (stretch_filter && !stretch_filter_prev) {
|
||||
Screen::get()->toggleStretchFilter();
|
||||
Overlay::showNotification(Options::video.stretch_filter_linear ? Locale::get("notifications.filter_linear") : Locale::get("notifications.filter_nearest"));
|
||||
}
|
||||
if (stretch_filter) consumed = true;
|
||||
stretch_filter_prev = stretch_filter;
|
||||
|
||||
// F10 — Toggle render info (FPS, driver, shader)
|
||||
bool render_info = JI_KeyPressed(Options::keys_gui.toggle_render_info);
|
||||
if (render_info && !render_info_prev) {
|
||||
});
|
||||
consumed |= edgeTrigger("cycle_texture_filter", texture_filter_prev, [] {
|
||||
Screen::get()->cycleTextureFilter(+1);
|
||||
Overlay::showNotification(Options::video.texture_filter == Options::TextureFilter::LINEAR
|
||||
? Locale::get("notifications.filter_linear")
|
||||
: Locale::get("notifications.filter_nearest"));
|
||||
});
|
||||
consumed |= edgeTrigger("toggle_render_info", render_info_prev, [] {
|
||||
Overlay::toggleRenderInfo();
|
||||
}
|
||||
if (render_info) consumed = true;
|
||||
render_info_prev = render_info;
|
||||
|
||||
});
|
||||
return consumed;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
namespace GlobalInputs {
|
||||
// Comprovar una vegada per frame, després de JI_Update()
|
||||
// Comprovar una vegada per frame, després de Ji::update()
|
||||
// Retorna true si ha consumit alguna tecla (per suprimir-la de la capa de joc)
|
||||
auto handle() -> bool;
|
||||
} // namespace GlobalInputs
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
#include "core/input/key_config.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <utility>
|
||||
|
||||
#include "core/resources/resource_helper.hpp"
|
||||
#include "external/fkyaml_node.hpp"
|
||||
|
||||
namespace KeyConfig {
|
||||
|
||||
namespace {
|
||||
std::vector<KeyEntry> key_entries;
|
||||
std::unordered_map<std::string, size_t> index_table;
|
||||
std::string overrides_path;
|
||||
|
||||
auto findIndex(const std::string& id) -> size_t {
|
||||
auto it = index_table.find(id);
|
||||
if (it == index_table.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_table[entry.id] = key_entries.size();
|
||||
key_entries.push_back(std::move(entry));
|
||||
}
|
||||
std::cout << "KeyConfig: " << key_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;
|
||||
}
|
||||
key_entries[idx].scancode = sc;
|
||||
key_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) {
|
||||
key_entries.clear();
|
||||
index_table.clear();
|
||||
overrides_path = user_overrides_disk_path;
|
||||
|
||||
loadDefaults(defaults_resource_path);
|
||||
if (!overrides_path.empty()) {
|
||||
applyOverrides(overrides_path);
|
||||
}
|
||||
}
|
||||
|
||||
void destroy() {
|
||||
key_entries.clear();
|
||||
index_table.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 key_entries[idx].scancode;
|
||||
}
|
||||
|
||||
auto scancodePtr(const std::string& id) -> SDL_Scancode* {
|
||||
auto idx = findIndex(id);
|
||||
if (idx == SIZE_MAX) {
|
||||
return nullptr;
|
||||
}
|
||||
return &key_entries[idx].scancode;
|
||||
}
|
||||
|
||||
void setScancode(const std::string& id, SDL_Scancode sc) {
|
||||
auto idx = findIndex(id);
|
||||
if (idx == SIZE_MAX) {
|
||||
return;
|
||||
}
|
||||
key_entries[idx].scancode = sc;
|
||||
const char* name = SDL_GetScancodeName(sc);
|
||||
key_entries[idx].code = (name != nullptr) ? name : "";
|
||||
}
|
||||
|
||||
auto isGuiKey(SDL_Scancode sc) -> bool {
|
||||
if (sc == SDL_SCANCODE_UNKNOWN) {
|
||||
return false;
|
||||
}
|
||||
return std::ranges::any_of(key_entries, [sc](const auto& e) { return e.scancode == sc; });
|
||||
}
|
||||
|
||||
auto entries() -> const std::vector<KeyEntry>& {
|
||||
return key_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 : key_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
|
||||
@@ -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
|
||||
@@ -9,15 +9,17 @@ namespace KeyRemap {
|
||||
|
||||
static void mirror(SDL_Scancode custom, SDL_Scancode standard, const bool* ks) {
|
||||
if (custom == standard || custom == SDL_SCANCODE_UNKNOWN) {
|
||||
JI_SetVirtualKey(standard, JI_VSRC_REMAP, false);
|
||||
Ji::setVirtualKey(standard, Ji::VirtualSource::REMAP, false);
|
||||
return;
|
||||
}
|
||||
JI_SetVirtualKey(standard, JI_VSRC_REMAP, ks[custom]);
|
||||
Ji::setVirtualKey(standard, Ji::VirtualSource::REMAP, ks[custom]);
|
||||
}
|
||||
|
||||
void update() {
|
||||
const bool* ks = SDL_GetKeyboardState(nullptr);
|
||||
if (!ks) return;
|
||||
if (ks == nullptr) {
|
||||
return;
|
||||
}
|
||||
mirror(Options::keys_game.up, SDL_SCANCODE_UP, ks);
|
||||
mirror(Options::keys_game.down, SDL_SCANCODE_DOWN, ks);
|
||||
mirror(Options::keys_game.left, SDL_SCANCODE_LEFT, ks);
|
||||
|
||||
@@ -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(¤t_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
|
||||
@@ -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);
|
||||
@@ -1,9 +1,10 @@
|
||||
#include "core/jail/jdraw8.hpp"
|
||||
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
|
||||
#include "core/jail/jfile.hpp"
|
||||
#include "core/system/director.hpp"
|
||||
#include "core/resources/resource_cache.hpp"
|
||||
#include "core/resources/resource_helper.hpp"
|
||||
#if defined(__clang__)
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Wunused-but-set-variable"
|
||||
@@ -11,87 +12,144 @@
|
||||
#pragma GCC diagnostic push
|
||||
#pragma GCC diagnostic ignored "-Wunused-but-set-variable"
|
||||
#endif
|
||||
#include "external/gif.h"
|
||||
// NOLINTBEGIN(clang-analyzer-unix.Malloc): codi extern de tercers, no l'auditem.
|
||||
#include <gif.h> // tercer-part: resolt via SYSTEM include path (source/external/)
|
||||
// NOLINTEND(clang-analyzer-unix.Malloc)
|
||||
#if defined(__clang__)
|
||||
#pragma clang diagnostic pop
|
||||
#elif defined(__GNUC__)
|
||||
#pragma GCC diagnostic pop
|
||||
#endif
|
||||
|
||||
JD8_Surface screen = NULL;
|
||||
JD8_Palette main_palette = NULL;
|
||||
Uint32* pixel_data = NULL;
|
||||
Jd8::Surface screen = nullptr;
|
||||
Jd8::Palette main_palette = nullptr;
|
||||
Uint32* pixel_data = nullptr;
|
||||
|
||||
void JD8_Init() {
|
||||
screen = (JD8_Surface)calloc(1, 64000);
|
||||
main_palette = (JD8_Palette)calloc(1, 768);
|
||||
pixel_data = (Uint32*)calloc(1, 320 * 200 * 4);
|
||||
void Jd8::init() {
|
||||
screen = new Uint8[64000]{};
|
||||
main_palette = new Color[256]{};
|
||||
pixel_data = new Uint32[std::size_t{320} * 200]{};
|
||||
}
|
||||
|
||||
void JD8_Quit() {
|
||||
if (screen != NULL) free(screen);
|
||||
if (main_palette != NULL) free(main_palette);
|
||||
if (pixel_data != NULL) free(pixel_data);
|
||||
void Jd8::quit() {
|
||||
delete[] screen;
|
||||
delete[] main_palette;
|
||||
delete[] pixel_data;
|
||||
screen = nullptr;
|
||||
main_palette = nullptr;
|
||||
pixel_data = nullptr;
|
||||
}
|
||||
|
||||
void JD8_ClearScreen(Uint8 color) {
|
||||
void Jd8::clearScreen(Uint8 color) {
|
||||
memset(screen, color, 64000);
|
||||
}
|
||||
|
||||
JD8_Surface JD8_NewSurface() {
|
||||
JD8_Surface surface = (JD8_Surface)malloc(64000);
|
||||
memset(surface, 0, 64000);
|
||||
return surface;
|
||||
auto Jd8::newSurface() -> Jd8::Surface {
|
||||
return new Uint8[64000]{};
|
||||
}
|
||||
|
||||
JD8_Surface JD8_LoadSurface(const char* file) {
|
||||
int filesize = 0;
|
||||
char* buffer = file_getfilebuffer(file, filesize);
|
||||
// Helper intern: deriva el basename d'una ruta per a buscar al Cache.
|
||||
static auto pathBasename(const char* file) -> std::string {
|
||||
std::string s = file;
|
||||
auto pos = s.find_last_of("/\\");
|
||||
return pos == std::string::npos ? s : s.substr(pos + 1);
|
||||
}
|
||||
|
||||
unsigned short w, h;
|
||||
Uint8* pixels = LoadGif((unsigned char*)buffer, &w, &h);
|
||||
auto Jd8::loadSurface(const char* file) -> Jd8::Surface {
|
||||
// Prova primer el Resource::Cache. Si l'asset és precarregat, copiem
|
||||
// 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(pathBasename(file));
|
||||
Jd8::Surface image = Jd8::newSurface();
|
||||
memcpy(image, cached.data(), 64000);
|
||||
return image;
|
||||
} catch (const std::exception&) {
|
||||
// @INTENTIONAL: no està al cache (asset no llistat al manifest), fallback al loader.
|
||||
}
|
||||
}
|
||||
|
||||
free(buffer);
|
||||
|
||||
if (pixels == NULL) {
|
||||
auto buffer = ResourceHelper::loadFile(file);
|
||||
unsigned short w;
|
||||
unsigned short h;
|
||||
Uint8* pixels = LoadGif(buffer.data(), &w, &h);
|
||||
if (pixels == nullptr) {
|
||||
printf("Unable to load bitmap: %s\n", SDL_GetError());
|
||||
exit(1);
|
||||
}
|
||||
|
||||
JD8_Surface image = JD8_NewSurface();
|
||||
Jd8::Surface image = Jd8::newSurface();
|
||||
memcpy(image, pixels, 64000);
|
||||
|
||||
free(pixels);
|
||||
return image;
|
||||
}
|
||||
|
||||
JD8_Palette JD8_LoadPalette(const char* file) {
|
||||
int filesize = 0;
|
||||
char* buffer = NULL;
|
||||
buffer = file_getfilebuffer(file, filesize);
|
||||
auto Jd8::loadPalette(const char* file) -> Jd8::Palette {
|
||||
// Sempre retorna un buffer de 256 colors reservat amb `new Color[256]`
|
||||
// — el caller és responsable d'alliberar-lo amb `delete[]` (o lliurar-ne
|
||||
// l'ownership a `Jd8::setScreenPalette`).
|
||||
auto* 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(pathBasename(file));
|
||||
memcpy(palette, cached.data(), 768);
|
||||
return palette;
|
||||
} catch (const std::exception&) {
|
||||
// @INTENTIONAL: 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;
|
||||
}
|
||||
|
||||
void JD8_SetScreenPalette(JD8_Palette palette) {
|
||||
if (main_palette == palette) return;
|
||||
if (main_palette != NULL) free(main_palette);
|
||||
void Jd8::setScreenPalette(Jd8::Palette palette) {
|
||||
if (main_palette == palette) {
|
||||
return;
|
||||
}
|
||||
delete[] main_palette;
|
||||
main_palette = palette;
|
||||
}
|
||||
|
||||
void JD8_FillSquare(int ini, int height, Uint8 color) {
|
||||
const int offset = ini * 320;
|
||||
const int size = height * 320;
|
||||
memset(&screen[offset], color, size);
|
||||
void Jd8::fillSquare(int ini, int height, Uint8 color) {
|
||||
const int OFFSET = ini * 320;
|
||||
const int SIZE = height * 320;
|
||||
memset(&screen[OFFSET], color, SIZE);
|
||||
}
|
||||
|
||||
void JD8_Blit(JD8_Surface surface) {
|
||||
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(const Uint8* surface) {
|
||||
memcpy(screen, surface, 64000);
|
||||
}
|
||||
|
||||
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, const Uint8* surface, int sx, int sy, int sw, int sh) {
|
||||
int src_pointer = sx + (sy * 320);
|
||||
int dst_pointer = x + (y * 320);
|
||||
for (int i = 0; i < sh; i++) {
|
||||
@@ -101,7 +159,7 @@ void JD8_Blit(int x, int y, JD8_Surface surface, int sx, int sy, int sw, int sh)
|
||||
}
|
||||
}
|
||||
|
||||
void JD8_BlitToSurface(int x, int y, JD8_Surface surface, int sx, int sy, int sw, int sh, JD8_Surface dest) {
|
||||
void Jd8::blitToSurface(int x, int y, const Uint8* surface, int sx, int sy, int sw, int sh, Jd8::Surface dest) {
|
||||
int src_pointer = sx + (sy * 320);
|
||||
int dst_pointer = x + (y * 320);
|
||||
for (int i = 0; i < sh; i++) {
|
||||
@@ -111,119 +169,170 @@ void JD8_BlitToSurface(int x, int y, JD8_Surface surface, int sx, int sy, int sw
|
||||
}
|
||||
}
|
||||
|
||||
void JD8_BlitCK(int x, int y, JD8_Surface surface, int sx, int sy, int sw, int sh, Uint8 colorkey) {
|
||||
void Jd8::blitCK(int x, int y, const Uint8* surface, int sx, int sy, int sw, int sh, Uint8 colorkey) {
|
||||
int src_pointer = sx + (sy * 320);
|
||||
int dst_pointer = x + (y * 320);
|
||||
for (int j = 0; j < sh; j++) {
|
||||
for (int i = 0; i < sw; i++) {
|
||||
if (surface[src_pointer + i] != colorkey) screen[dst_pointer + i] = surface[src_pointer + i];
|
||||
if (surface[src_pointer + i] != colorkey) {
|
||||
screen[dst_pointer + i] = surface[src_pointer + i];
|
||||
}
|
||||
}
|
||||
src_pointer += 320;
|
||||
dst_pointer += 320;
|
||||
}
|
||||
}
|
||||
|
||||
void JD8_BlitCKCut(int x, int y, JD8_Surface surface, int sx, int sy, int sw, int sh, Uint8 colorkey) {
|
||||
void Jd8::blitCKCut(int x, int y, const Uint8* surface, int sx, int sy, int sw, int sh, Uint8 colorkey) {
|
||||
int src_pointer = sx + (sy * 320);
|
||||
int dst_pointer = x + (y * 320);
|
||||
for (int j = 0; j < sh; j++) {
|
||||
for (int i = 0; i < sw; i++) {
|
||||
if (surface[src_pointer + i] != colorkey && (x + i >= 0) && (y + j >= 0) && (x + i < 320) && (y + j < 200)) screen[dst_pointer + i] = surface[src_pointer + i];
|
||||
if (surface[src_pointer + i] != colorkey && (x + i >= 0) && (y + j >= 0) && (x + i < 320) && (y + j < 200)) {
|
||||
screen[dst_pointer + i] = surface[src_pointer + i];
|
||||
}
|
||||
}
|
||||
src_pointer += 320;
|
||||
dst_pointer += 320;
|
||||
}
|
||||
}
|
||||
|
||||
void JD8_BlitCKScroll(int y, JD8_Surface surface, int sx, int sy, int sh, Uint8 colorkey) {
|
||||
void Jd8::blitCKScroll(int y, const Uint8* surface, int sx, int sy, int sh, Uint8 colorkey) {
|
||||
int dst_pointer = y * 320;
|
||||
for (int j = sy; j < sy + sh; j++) {
|
||||
for (int i = 0; i < 320; i++) {
|
||||
int x = (i + sx) % 320;
|
||||
if (surface[x + j * 320] != colorkey) screen[dst_pointer] = surface[x + j * 320];
|
||||
if (surface[x + (j * 320)] != colorkey) {
|
||||
screen[dst_pointer] = surface[x + (j * 320)];
|
||||
}
|
||||
dst_pointer++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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, const Uint8* surface, int sx, int sy, int sw, int sh, Jd8::Surface dest, Uint8 colorkey) {
|
||||
int src_pointer = sx + (sy * 320);
|
||||
int dst_pointer = x + (y * 320);
|
||||
for (int j = 0; j < sh; j++) {
|
||||
for (int i = 0; i < sw; i++) {
|
||||
if (surface[src_pointer + i] != colorkey) dest[dst_pointer + i] = surface[src_pointer + i];
|
||||
if (surface[src_pointer + i] != colorkey) {
|
||||
dest[dst_pointer + i] = surface[src_pointer + i];
|
||||
}
|
||||
}
|
||||
src_pointer += 320;
|
||||
dst_pointer += 320;
|
||||
}
|
||||
}
|
||||
|
||||
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 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);
|
||||
pixel_data[x + (y * 320)] = color;
|
||||
}
|
||||
}
|
||||
Director::get()->publishFrame(pixel_data);
|
||||
}
|
||||
|
||||
void JD8_FreeSurface(JD8_Surface surface) {
|
||||
free(surface);
|
||||
auto Jd8::getFramebuffer() -> Uint32* {
|
||||
return pixel_data;
|
||||
}
|
||||
|
||||
Uint8 JD8_GetPixel(JD8_Surface surface, int x, int y) {
|
||||
void Jd8::freeSurface(Jd8::Surface surface) { // NOLINT(readability-non-const-parameter): allibera memòria, no pot ser const
|
||||
delete[] surface;
|
||||
}
|
||||
|
||||
auto Jd8::getPixel(const Uint8* surface, int x, int y) -> Uint8 {
|
||||
return surface[x + (y * 320)];
|
||||
}
|
||||
|
||||
void JD8_PutPixel(JD8_Surface surface, int x, int y, Uint8 pixel) {
|
||||
void Jd8::putPixel(Jd8::Surface surface, int x, int y, Uint8 pixel) {
|
||||
surface[x + (y * 320)] = pixel;
|
||||
}
|
||||
|
||||
void JD8_SetPaletteColor(Uint8 index, Uint8 r, Uint8 g, Uint8 b) {
|
||||
void Jd8::setPaletteColor(Uint8 index, Uint8 r, Uint8 g, Uint8 b) {
|
||||
main_palette[index].r = r << 2;
|
||||
main_palette[index].g = g << 2;
|
||||
main_palette[index].b = b << 2;
|
||||
}
|
||||
|
||||
void JD8_FadeOut() {
|
||||
for (int j = 0; j < 32; j++) {
|
||||
// Màquina d'estats del fade. Evita que JD8_FadeOut/JD8_FadeToPal hagen de
|
||||
// mantindre whiles interns. Cada pas aplica un delta a la paleta activa i
|
||||
// el caller decideix quan fer Flip.
|
||||
namespace {
|
||||
|
||||
enum class FadeType : std::uint8_t {
|
||||
NONE = 0,
|
||||
OUT,
|
||||
TO_PAL,
|
||||
};
|
||||
|
||||
constexpr int FADE_STEPS = 32;
|
||||
|
||||
FadeType fade_type = FadeType::NONE;
|
||||
Color fade_target[256];
|
||||
int fade_step = 0;
|
||||
|
||||
void applyFadeStep() {
|
||||
if (fade_type == FadeType::OUT) {
|
||||
for (int i = 0; i < 256; i++) {
|
||||
if (main_palette[i].r >= 8)
|
||||
main_palette[i].r -= 8;
|
||||
else
|
||||
main_palette[i].r = 0;
|
||||
if (main_palette[i].g >= 8)
|
||||
main_palette[i].g -= 8;
|
||||
else
|
||||
main_palette[i].g = 0;
|
||||
if (main_palette[i].b >= 8)
|
||||
main_palette[i].b -= 8;
|
||||
else
|
||||
main_palette[i].b = 0;
|
||||
main_palette[i].r = main_palette[i].r >= 8 ? main_palette[i].r - 8 : 0;
|
||||
main_palette[i].g = main_palette[i].g >= 8 ? main_palette[i].g - 8 : 0;
|
||||
main_palette[i].b = main_palette[i].b >= 8 ? main_palette[i].b - 8 : 0;
|
||||
}
|
||||
JD8_Flip();
|
||||
} else if (fade_type == FadeType::TO_PAL) {
|
||||
for (int i = 0; i < 256; i++) {
|
||||
main_palette[i].r = main_palette[i].r <= int(fade_target[i].r) - 8
|
||||
? main_palette[i].r + 8
|
||||
: fade_target[i].r;
|
||||
main_palette[i].g = main_palette[i].g <= int(fade_target[i].g) - 8
|
||||
? main_palette[i].g + 8
|
||||
: 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void Jd8::fadeStartOut() {
|
||||
fade_type = FadeType::OUT;
|
||||
fade_step = 0;
|
||||
}
|
||||
|
||||
#define MAX(a, b) (a) > (b) ? (a) : (b)
|
||||
|
||||
void JD8_FadeToPal(JD8_Palette pal) {
|
||||
for (int j = 0; j < 32; j++) {
|
||||
for (int i = 0; i < 256; i++) {
|
||||
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(const Color* pal) {
|
||||
fade_type = FadeType::TO_PAL;
|
||||
memcpy(fade_target, pal, sizeof(Color) * 256);
|
||||
fade_step = 0;
|
||||
}
|
||||
|
||||
auto Jd8::fadeIsActive() -> bool {
|
||||
return fade_type != FadeType::NONE;
|
||||
}
|
||||
|
||||
auto Jd8::fadeTickStep() -> bool {
|
||||
if (fade_type == FadeType::NONE) {
|
||||
return true;
|
||||
}
|
||||
|
||||
applyFadeStep();
|
||||
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`).
|
||||
|
||||
@@ -7,55 +7,72 @@ struct Color {
|
||||
Uint8 b;
|
||||
};
|
||||
|
||||
typedef Uint8* JD8_Surface;
|
||||
typedef Color* JD8_Palette;
|
||||
namespace Jd8 {
|
||||
|
||||
void JD8_Init();
|
||||
using Surface = Uint8*;
|
||||
using Palette = Color*;
|
||||
|
||||
void JD8_Quit();
|
||||
void init();
|
||||
|
||||
void JD8_ClearScreen(Uint8 color);
|
||||
void quit();
|
||||
|
||||
JD8_Surface JD8_NewSurface();
|
||||
void clearScreen(Uint8 color);
|
||||
|
||||
JD8_Surface JD8_LoadSurface(const char* file);
|
||||
auto newSurface() -> Surface;
|
||||
|
||||
JD8_Palette JD8_LoadPalette(const char* file);
|
||||
auto loadSurface(const char* file) -> Surface;
|
||||
|
||||
void JD8_SetScreenPalette(JD8_Palette palette);
|
||||
auto loadPalette(const char* file) -> Palette;
|
||||
|
||||
void JD8_FillSquare(int ini, int height, Uint8 color);
|
||||
void setScreenPalette(Palette palette);
|
||||
|
||||
void JD8_Blit(JD8_Surface surface);
|
||||
void fillSquare(int ini, int height, Uint8 color);
|
||||
|
||||
void JD8_Blit(int x, int y, JD8_Surface surface, int sx, int sy, int sw, int sh);
|
||||
// Omple un rectangle arbitrari de la pantalla amb un color paletat.
|
||||
// Pensat per a UI senzilla (barra de progrés del BootLoader, etc.).
|
||||
void fillRect(int x, int y, int w, int h, Uint8 color);
|
||||
|
||||
void JD8_BlitToSurface(int x, int y, JD8_Surface surface, int sx, int sy, int sw, int sh, JD8_Surface dest);
|
||||
void blit(const Uint8* surface);
|
||||
|
||||
void JD8_BlitCK(int x, int y, JD8_Surface surface, int sx, int sy, int sw, int sh, Uint8 colorkey);
|
||||
void blit(int x, int y, const Uint8* surface, int sx, int sy, int sw, int sh);
|
||||
|
||||
void JD8_BlitCKCut(int x, int y, JD8_Surface surface, int sx, int sy, int sw, int sh, Uint8 colorkey);
|
||||
void blitToSurface(int x, int y, const Uint8* surface, int sx, int sy, int sw, int sh, Surface dest);
|
||||
|
||||
void JD8_BlitCKScroll(int y, JD8_Surface surface, int sx, int sy, int sh, Uint8 colorkey);
|
||||
void blitCK(int x, int y, const Uint8* surface, int sx, int sy, int sw, int sh, 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);
|
||||
void blitCKCut(int x, int y, const Uint8* surface, int sx, int sy, int sw, int sh, Uint8 colorkey);
|
||||
|
||||
void JD8_Flip();
|
||||
void blitCKScroll(int y, const Uint8* surface, int sx, int sy, int sh, Uint8 colorkey);
|
||||
|
||||
void JD8_FreeSurface(JD8_Surface surface);
|
||||
void blitCKToSurface(int x, int y, const Uint8* surface, int sx, int sy, int sw, int sh, Surface dest, Uint8 colorkey);
|
||||
|
||||
Uint8 JD8_GetPixel(JD8_Surface surface, int x, int y);
|
||||
// Converteix la pantalla indexada a ARGB. El Director crida aquesta
|
||||
// funció al final de cada tick i després llegeix el framebuffer via
|
||||
// getFramebuffer() per presentar-lo.
|
||||
void flip();
|
||||
|
||||
void JD8_PutPixel(JD8_Surface surface, int x, int y, Uint8 pixel);
|
||||
// Accés al framebuffer ARGB de 320x200 actualitzat per l'última crida a
|
||||
// flip(). Propietat de jdraw8 — el caller no ha de lliberar-lo.
|
||||
auto getFramebuffer() -> Uint32*;
|
||||
|
||||
void JD8_SetPaletteColor(Uint8 index, Uint8 r, Uint8 g, Uint8 b);
|
||||
void freeSurface(Surface surface);
|
||||
|
||||
void JD8_FadeOut();
|
||||
auto getPixel(const Uint8* surface, int x, int y) -> Uint8;
|
||||
|
||||
void JD8_FadeToPal(JD8_Palette pal);
|
||||
void putPixel(Surface surface, int x, int y, Uint8 pixel);
|
||||
|
||||
// JD_Font JD_LoadFont( char *file, int width, int height);
|
||||
void setPaletteColor(Uint8 index, Uint8 r, Uint8 g, Uint8 b);
|
||||
|
||||
// void JD_DrawText( int x, int y, JD_Font *source, char *text);
|
||||
// API de fade no bloquejant (màquina d'estats). `fadeStart*` inicia el
|
||||
// fade; `fadeTickStep` aplica un pas i retorna `true` quan el fade ha
|
||||
// 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 fadeStartOut();
|
||||
void fadeStartToPal(const Color* pal);
|
||||
auto fadeTickStep() -> bool;
|
||||
auto fadeIsActive() -> bool;
|
||||
|
||||
// char *JD_GetFPS();
|
||||
} // namespace Jd8
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
#include "core/jail/jfile.hpp"
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -17,202 +13,124 @@
|
||||
#include <pwd.h>
|
||||
#endif
|
||||
|
||||
#define DEFAULT_FILENAME "data.jf2"
|
||||
#define DEFAULT_FOLDER "data/"
|
||||
#define CONFIG_FILENAME "config.txt"
|
||||
namespace {
|
||||
|
||||
struct file_t {
|
||||
std::string path;
|
||||
uint32_t size;
|
||||
uint32_t offset;
|
||||
};
|
||||
struct Keyvalue {
|
||||
std::string key;
|
||||
std::string value;
|
||||
};
|
||||
|
||||
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 */
|
||||
struct keyvalue_t {
|
||||
std::string key, value;
|
||||
};
|
||||
|
||||
char* resource_filename = NULL;
|
||||
char* resource_folder = NULL;
|
||||
int file_source = SOURCE_FILE;
|
||||
char scratch[255];
|
||||
static std::string config_folder;
|
||||
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 file_setresourcefolder(const char* str) {
|
||||
if (resource_folder != NULL) free(resource_folder);
|
||||
resource_folder = (char*)malloc(strlen(str) + 1);
|
||||
strcpy(resource_folder, str);
|
||||
}
|
||||
|
||||
void file_setsource(const int src) {
|
||||
file_source = src % 2; // mod 2 so it always is a valid value, 0 (file) or 1 (folder)
|
||||
if (src == SOURCE_FOLDER && resource_folder == NULL) file_setresourcefolder(DEFAULT_FOLDER);
|
||||
}
|
||||
|
||||
bool file_getdictionary() {
|
||||
if (resource_filename == NULL) file_setresourcefilename(DEFAULT_FILENAME);
|
||||
|
||||
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);
|
||||
void loadConfigValues() {
|
||||
config.clear();
|
||||
const std::string CONFIG_FILE = config_folder + "/config.txt";
|
||||
std::ifstream fi(CONFIG_FILE);
|
||||
if (!fi.is_open()) {
|
||||
return;
|
||||
}
|
||||
|
||||
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++;
|
||||
std::string line;
|
||||
while (std::getline(fi, line)) {
|
||||
const auto EQ = line.find('=');
|
||||
if (EQ == std::string::npos) {
|
||||
continue;
|
||||
}
|
||||
config.push_back({line.substr(0, EQ), line.substr(EQ + 1)});
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
perror("El recurs no s'ha trobat en l'arxiu de recursos");
|
||||
exit(1);
|
||||
void saveConfigValues() {
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
filesize = toc[count].size;
|
||||
} // namespace
|
||||
|
||||
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;
|
||||
void Jf::setResourceFolder(const char* str) {
|
||||
resource_folder = str;
|
||||
}
|
||||
|
||||
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;
|
||||
auto Jf::getResourceFolder() -> const char* {
|
||||
return resource_folder.c_str();
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Crea la carpeta del sistema on guardar les dades.
|
||||
// Accepta rutes amb subdirectoris (ex: "jailgames/aee") i crea tota la jerarquia.
|
||||
void Jf::setConfigFolder(const char* foldername) {
|
||||
#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__
|
||||
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;
|
||||
#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());
|
||||
const char* homedir = pw->pw_dir;
|
||||
const char* homedir = ((pw != nullptr) && (pw->pw_dir != nullptr) && (pw->pw_dir[0] != 0)) ? pw->pw_dir : nullptr;
|
||||
if ((homedir == nullptr) || (homedir[0] == 0)) {
|
||||
homedir = getenv("HOME");
|
||||
}
|
||||
if ((homedir == nullptr) || (homedir[0] == 0)) {
|
||||
homedir = "/tmp";
|
||||
}
|
||||
config_folder = std::string(homedir) + "/.config/" + foldername;
|
||||
#else
|
||||
config_folder = std::string("/tmp/jailgames_config/") + foldername;
|
||||
#endif
|
||||
|
||||
std::filesystem::create_directories(config_folder);
|
||||
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() {
|
||||
static std::string folder;
|
||||
folder = config_folder + "/";
|
||||
return folder.c_str();
|
||||
auto Jf::getConfigFolder() -> const char* {
|
||||
thread_local std::string folder_;
|
||||
folder_ = config_folder + "/";
|
||||
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});
|
||||
auto Jf::getConfigValue(const char* key) -> const char* {
|
||||
if (config.empty()) {
|
||||
loadConfigValues();
|
||||
}
|
||||
const auto IT = std::ranges::find_if(config, [key](const Keyvalue& pair) { return pair.key == key; });
|
||||
if (IT != config.end()) {
|
||||
thread_local std::string value_cache_;
|
||||
value_cache_ = IT->value;
|
||||
return value_cache_.c_str();
|
||||
}
|
||||
fclose(f);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
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());
|
||||
void Jf::setConfigValue(const char* key, const char* value) {
|
||||
if (config.empty()) {
|
||||
loadConfigValues();
|
||||
}
|
||||
fclose(f);
|
||||
}
|
||||
}
|
||||
|
||||
const char* file_getconfigvalue(const char* key) {
|
||||
if (config.empty()) file_loadconfigvalues();
|
||||
for (auto pair : config) {
|
||||
if (pair.key == std::string(key)) {
|
||||
strcpy(scratch, pair.value.c_str());
|
||||
return scratch;
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
void file_setconfigvalue(const char* key, const char* value) {
|
||||
if (config.empty()) file_loadconfigvalues();
|
||||
for (auto& pair : config) {
|
||||
if (pair.key == std::string(key)) {
|
||||
pair.value = value;
|
||||
file_saveconfigvalues();
|
||||
const auto IT = std::ranges::find_if(config, [key](const Keyvalue& pair) { return pair.key == key; });
|
||||
if (IT != config.end()) {
|
||||
IT->value = value;
|
||||
saveConfigValues();
|
||||
return;
|
||||
}
|
||||
}
|
||||
config.push_back({key, value});
|
||||
file_saveconfigvalues();
|
||||
return;
|
||||
config.push_back({std::string(key), std::string(value)});
|
||||
saveConfigValues();
|
||||
}
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
#pragma once
|
||||
#include <stdio.h>
|
||||
|
||||
#define SOURCE_FILE 0
|
||||
#define SOURCE_FOLDER 1
|
||||
namespace Jf {
|
||||
|
||||
void file_setconfigfolder(const char* foldername);
|
||||
const char* file_getconfigfolder();
|
||||
void setConfigFolder(const char* foldername);
|
||||
auto getConfigFolder() -> const char*;
|
||||
|
||||
void file_setresourcefilename(const char* str);
|
||||
void file_setresourcefolder(const char* str);
|
||||
void file_setsource(const int src);
|
||||
void setResourceFolder(const char* str);
|
||||
auto getResourceFolder() -> const char*;
|
||||
|
||||
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);
|
||||
auto getConfigValue(const char* key) -> const char*;
|
||||
void setConfigValue(const char* key, const char* value);
|
||||
|
||||
const char* file_getconfigvalue(const char* key);
|
||||
void file_setconfigvalue(const char* key, const char* value);
|
||||
} // namespace Jf
|
||||
|
||||
@@ -1,42 +1,57 @@
|
||||
#include "core/jail/jgame.hpp"
|
||||
|
||||
bool eixir = false;
|
||||
Uint32 updateTicks = 0;
|
||||
Uint32 updateTime = 0;
|
||||
Uint32 cycle_counter = 0;
|
||||
namespace {
|
||||
|
||||
void JG_Init() {
|
||||
bool is_quitting = false;
|
||||
Uint32 update_ticks = 0;
|
||||
Uint32 update_time = 0;
|
||||
Uint32 cycle_counter = 0;
|
||||
Uint32 last_delta_time = 0;
|
||||
|
||||
} // namespace
|
||||
|
||||
void Jg::init() {
|
||||
SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO);
|
||||
// SDL_WM_SetCaption( title, NULL );
|
||||
updateTime = SDL_GetTicks();
|
||||
update_time = SDL_GetTicks();
|
||||
last_delta_time = update_time;
|
||||
}
|
||||
|
||||
void JG_Finalize() {
|
||||
void Jg::finalize() {
|
||||
SDL_Quit();
|
||||
}
|
||||
|
||||
void JG_QuitSignal() {
|
||||
eixir = true;
|
||||
void Jg::quitSignal() {
|
||||
is_quitting = true;
|
||||
}
|
||||
|
||||
bool JG_Quitting() {
|
||||
return eixir;
|
||||
auto Jg::quitting() -> bool {
|
||||
return is_quitting;
|
||||
}
|
||||
|
||||
void JG_SetUpdateTicks(Uint32 milliseconds) {
|
||||
updateTicks = milliseconds;
|
||||
void Jg::setUpdateTicks(Uint32 milliseconds) {
|
||||
update_ticks = milliseconds;
|
||||
}
|
||||
|
||||
bool JG_ShouldUpdate() {
|
||||
if (SDL_GetTicks() - updateTime > updateTicks) {
|
||||
updateTime = SDL_GetTicks();
|
||||
auto Jg::shouldUpdate() -> bool {
|
||||
const Uint32 NOW = SDL_GetTicks();
|
||||
if (NOW - update_time > update_ticks) {
|
||||
update_time = NOW;
|
||||
cycle_counter++;
|
||||
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() {
|
||||
auto Jg::getCycleCounter() -> Uint32 {
|
||||
return cycle_counter;
|
||||
}
|
||||
|
||||
auto Jg::getDeltaMs() -> Uint32 {
|
||||
const Uint32 NOW = SDL_GetTicks();
|
||||
const Uint32 DELTA = NOW - last_delta_time;
|
||||
last_delta_time = NOW;
|
||||
return DELTA;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
#pragma once
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
void JG_Init();
|
||||
namespace Jg {
|
||||
|
||||
void JG_Finalize();
|
||||
void init();
|
||||
|
||||
void JG_QuitSignal();
|
||||
void finalize();
|
||||
|
||||
bool JG_Quitting();
|
||||
void quitSignal();
|
||||
|
||||
void JG_SetUpdateTicks(Uint32 milliseconds);
|
||||
auto quitting() -> bool;
|
||||
|
||||
bool JG_ShouldUpdate();
|
||||
void setUpdateTicks(Uint32 milliseconds);
|
||||
|
||||
Uint32 JG_GetCycleCounter();
|
||||
auto shouldUpdate() -> bool;
|
||||
|
||||
auto getCycleCounter() -> Uint32;
|
||||
|
||||
// Temps transcorregut (en ms) des de l'última crida a Jg::getDeltaMs.
|
||||
// Helper per a la migració progressiva a time-based (Fase 4+).
|
||||
auto getDeltaMs() -> Uint32;
|
||||
|
||||
} // namespace Jg
|
||||
|
||||
@@ -1,76 +1,131 @@
|
||||
#include "core/jail/jinput.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
#include "core/system/director.hpp"
|
||||
|
||||
// keystates és actualitzat per SDL internament. Des del joc només fem lectures.
|
||||
const bool* keystates = nullptr;
|
||||
Uint8 cheat[5];
|
||||
bool key_pressed = false;
|
||||
int waitTime = 0;
|
||||
namespace {
|
||||
|
||||
void JI_DisableKeyboard(Uint32 time) {
|
||||
waitTime = time;
|
||||
// keystates és actualitzat per SDL internament. Des del joc només fem lectures.
|
||||
const bool* keystates = nullptr;
|
||||
|
||||
// Buffer dels últims 5 caràcters tecle. Emmagatzemem caràcters ASCII
|
||||
// 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[static_cast<size_t>(Ji::VirtualSource::COUNT)][SDL_SCANCODE_COUNT] = {{0}};
|
||||
|
||||
auto scancodeToAscii(Uint8 scancode) -> Uint8 {
|
||||
if (scancode >= SDL_SCANCODE_A && scancode <= SDL_SCANCODE_Z) {
|
||||
return static_cast<Uint8>('a' + (scancode - SDL_SCANCODE_A));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void Ji::disableKeyboard(Uint32 time) {
|
||||
wait_ms = static_cast<float>(time);
|
||||
}
|
||||
|
||||
static bool input_blocked = false;
|
||||
|
||||
void JI_SetInputBlocked(bool blocked) {
|
||||
void Ji::setInputBlocked(bool blocked) {
|
||||
input_blocked = blocked;
|
||||
}
|
||||
|
||||
static Uint8 virtual_keystates[JI_VSRC_COUNT][SDL_SCANCODE_COUNT] = {{0}};
|
||||
|
||||
void JI_SetVirtualKey(int scancode, int source, bool pressed) {
|
||||
if (scancode < 0 || scancode >= SDL_SCANCODE_COUNT) return;
|
||||
if (source < 0 || source >= JI_VSRC_COUNT) return;
|
||||
virtual_keystates[source][scancode] = pressed ? 1 : 0;
|
||||
void Ji::setVirtualKey(int scancode, VirtualSource source, bool pressed) {
|
||||
if (scancode < 0 || scancode >= SDL_SCANCODE_COUNT) {
|
||||
return;
|
||||
}
|
||||
const auto SRC_IDX = static_cast<size_t>(source);
|
||||
if (SRC_IDX >= static_cast<size_t>(VirtualSource::COUNT)) {
|
||||
return;
|
||||
}
|
||||
virtual_keystates[SRC_IDX][scancode] = pressed ? 1 : 0;
|
||||
}
|
||||
|
||||
void JI_moveCheats(Uint8 new_key) {
|
||||
void Ji::moveCheats(Uint8 scancode) {
|
||||
cheat[0] = cheat[1];
|
||||
cheat[1] = cheat[2];
|
||||
cheat[2] = cheat[3];
|
||||
cheat[3] = cheat[4];
|
||||
cheat[4] = new_key;
|
||||
cheat[4] = scancodeToAscii(scancode);
|
||||
}
|
||||
|
||||
void JI_Update() {
|
||||
void Ji::update() {
|
||||
// El director ha processat tots els events. Ací només refresquem
|
||||
// el snapshot del teclat i consumim el flag de tecla polsada.
|
||||
if (keystates == nullptr) {
|
||||
keystates = SDL_GetKeyboardState(NULL);
|
||||
keystates = SDL_GetKeyboardState(nullptr);
|
||||
}
|
||||
|
||||
if (waitTime > 0) waitTime--;
|
||||
const Uint64 NOW = SDL_GetTicks();
|
||||
if (last_update_tick == 0) {
|
||||
last_update_tick = NOW;
|
||||
}
|
||||
const auto DELTA_MS = static_cast<float>(NOW - last_update_tick);
|
||||
last_update_tick = NOW;
|
||||
|
||||
if (wait_ms > 0.0F) {
|
||||
wait_ms -= DELTA_MS;
|
||||
wait_ms = std::max(wait_ms, 0.0F);
|
||||
}
|
||||
|
||||
// Consumim el flag de "alguna tecla no-GUI polsada" del director
|
||||
key_pressed = Director::get()->consumeKeyPressed();
|
||||
}
|
||||
|
||||
bool JI_KeyPressed(int key) {
|
||||
if (waitTime > 0 || keystates == nullptr) return false;
|
||||
// Input bloquejat (p.ex. menú flotant obert)
|
||||
if (input_blocked) return false;
|
||||
// ESC bloquejada pel Director (primera pulsació mostra notificació)
|
||||
if (key == SDL_SCANCODE_ESCAPE && Director::get()->isEscBlocked()) return false;
|
||||
if (key < 0 || key >= SDL_SCANCODE_COUNT) return false;
|
||||
if (keystates[key] != 0) return true;
|
||||
for (int src = 0; src < JI_VSRC_COUNT; src++) {
|
||||
if (virtual_keystates[src][key] != 0) return true;
|
||||
}
|
||||
auto Ji::keyPressed(int key) -> bool {
|
||||
if (wait_ms > 0.0F || keystates == nullptr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool JI_CheatActivated(const char* cheat_code) {
|
||||
bool found = true;
|
||||
for (size_t i = 0; i < strlen(cheat_code); i++) {
|
||||
if (cheat[i] != cheat_code[i]) found = false;
|
||||
}
|
||||
return found;
|
||||
// Input bloquejat (p.ex. menú flotant obert)
|
||||
if (input_blocked) {
|
||||
return false;
|
||||
}
|
||||
// ESC bloquejada pel Director (primera pulsació mostra notificació)
|
||||
if (key == SDL_SCANCODE_ESCAPE && Director::get()->isEscBlocked()) {
|
||||
return false;
|
||||
}
|
||||
if (key < 0 || key >= SDL_SCANCODE_COUNT) {
|
||||
return false;
|
||||
}
|
||||
if (static_cast<int>(keystates[key]) != 0) {
|
||||
return true;
|
||||
}
|
||||
return std::ranges::any_of(virtual_keystates, [key](const auto& vk) { return vk[key] != 0; });
|
||||
}
|
||||
|
||||
bool JI_AnyKey() {
|
||||
return waitTime > 0 ? false : key_pressed;
|
||||
auto Ji::cheatActivated(const char* cheat_code) -> bool {
|
||||
const size_t LEN = std::strlen(cheat_code);
|
||||
if (LEN > sizeof(cheat)) {
|
||||
return 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 true;
|
||||
}
|
||||
|
||||
auto Ji::anyKey() -> bool {
|
||||
return wait_ms > 0.0F ? false : key_pressed;
|
||||
}
|
||||
|
||||
@@ -1,25 +1,36 @@
|
||||
#pragma once
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
void JI_DisableKeyboard(Uint32 time);
|
||||
#include <cstdint>
|
||||
|
||||
// Bloqueja tot l'input cap al joc (JI_KeyPressed retorna false per a tot)
|
||||
void JI_SetInputBlocked(bool blocked);
|
||||
namespace Ji {
|
||||
|
||||
// Estableix l'estat d'una tecla virtual. Múltiples fonts (gamepad, remap)
|
||||
// s'agrupen per OR. JI_KeyPressed retorna true si el teclat real O qualsevol
|
||||
// font virtual està premuda.
|
||||
enum JI_VirtualSource {
|
||||
JI_VSRC_GAMEPAD = 0,
|
||||
JI_VSRC_REMAP = 1,
|
||||
JI_VSRC_COUNT
|
||||
};
|
||||
void JI_SetVirtualKey(int scancode, int source, bool pressed);
|
||||
void disableKeyboard(Uint32 time);
|
||||
|
||||
void JI_Update();
|
||||
// Bloqueja tot l'input cap al joc (Ji::keyPressed retorna false per a tot)
|
||||
void setInputBlocked(bool blocked);
|
||||
|
||||
bool JI_KeyPressed(int key);
|
||||
// Estableix l'estat d'una tecla virtual. Múltiples fonts (gamepad, remap)
|
||||
// s'agrupen per OR. Ji::keyPressed retorna true si el teclat real O qualsevol
|
||||
// font virtual està premuda.
|
||||
enum class VirtualSource : std::uint8_t {
|
||||
GAMEPAD = 0,
|
||||
REMAP = 1,
|
||||
COUNT = 2
|
||||
};
|
||||
void setVirtualKey(int scancode, VirtualSource source, bool pressed);
|
||||
|
||||
bool JI_CheatActivated(const char* cheat_code);
|
||||
void update();
|
||||
|
||||
bool JI_AnyKey();
|
||||
// Avança el buffer rotatori de cheats afegint `scancode` per detectar
|
||||
// seqüències com "reviu", "alone", "obert". Usat pel Director quan rep
|
||||
// un KEY_DOWN; el joc no l'ha de cridar directament.
|
||||
void moveCheats(Uint8 scancode);
|
||||
|
||||
auto keyPressed(int key) -> bool;
|
||||
|
||||
auto cheatActivated(const char* cheat_code) -> bool;
|
||||
|
||||
auto anyKey() -> bool;
|
||||
|
||||
} // namespace Ji
|
||||
|
||||
@@ -4,43 +4,47 @@
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
#include "core/jail/jfile.hpp"
|
||||
#include "core/resources/resource_helper.hpp"
|
||||
#include "external/fkyaml_node.hpp"
|
||||
|
||||
namespace Locale {
|
||||
|
||||
static std::unordered_map<std::string, std::string> strings_;
|
||||
static std::unordered_map<std::string, std::string> strings_table;
|
||||
|
||||
// Aplana un node YAML en claus amb notació punt
|
||||
static void traverse(const fkyaml::node& node, const std::string& prefix) {
|
||||
if (node.is_mapping()) {
|
||||
for (auto it = node.begin(); it != node.end(); ++it) {
|
||||
std::string key = it.key().get_value<std::string>();
|
||||
std::string full = prefix.empty() ? key : prefix + "." + key;
|
||||
auto key = it.key().get_value<std::string>();
|
||||
std::string full = prefix;
|
||||
if (!full.empty()) {
|
||||
full += ".";
|
||||
}
|
||||
full += key;
|
||||
traverse(it.value(), full);
|
||||
}
|
||||
} else if (node.is_scalar()) {
|
||||
try {
|
||||
strings_[prefix] = node.get_value<std::string>();
|
||||
} catch (...) {}
|
||||
strings_table[prefix] = node.get_value<std::string>();
|
||||
} catch (...) {
|
||||
// @INTENTIONAL: si el valor no és string vàlid, l'ignorem i continuem.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool load(const char* filename) {
|
||||
int size = 0;
|
||||
char* buffer = file_getfilebuffer(filename, size, true);
|
||||
if (!buffer || size <= 0) {
|
||||
auto load(const char* filename) -> bool {
|
||||
auto buffer = ResourceHelper::loadFile(filename);
|
||||
if (buffer.empty()) {
|
||||
std::cerr << "Locale: unable to load " << filename << '\n';
|
||||
return false;
|
||||
}
|
||||
std::string content(buffer, size);
|
||||
free(buffer);
|
||||
std::string content(reinterpret_cast<const char*>(buffer.data()), buffer.size());
|
||||
|
||||
try {
|
||||
auto yaml = fkyaml::node::deserialize(content);
|
||||
strings_.clear();
|
||||
strings_table.clear();
|
||||
traverse(yaml, "");
|
||||
std::cout << "Locale loaded: " << strings_.size() << " string(s) from " << filename << '\n';
|
||||
std::cout << "Locale loaded: " << strings_table.size() << " string(s) from " << filename << '\n';
|
||||
return true;
|
||||
} catch (const fkyaml::exception& e) {
|
||||
std::cerr << "Locale: error parsing " << filename << ": " << e.what() << '\n';
|
||||
@@ -49,8 +53,10 @@ namespace Locale {
|
||||
}
|
||||
|
||||
auto get(const char* key) -> const char* {
|
||||
auto it = strings_.find(key);
|
||||
if (it != strings_.end()) return it->second.c_str();
|
||||
auto it = strings_table.find(key);
|
||||
if (it != strings_table.end()) {
|
||||
return it->second.c_str();
|
||||
}
|
||||
return key; // fallback: retorna la clau mateixa
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// Les claus són nested amb notació punt ("menu.items.zoom").
|
||||
// Si una clau no existeix, Locale::get torna la clau mateixa (útil per debug).
|
||||
namespace Locale {
|
||||
bool load(const char* filename);
|
||||
auto load(const char* filename) -> bool;
|
||||
|
||||
// Retorna la cadena associada a la clau. El punter és estable durant tota la
|
||||
// sessió (no canvia), per tant es pot guardar en const char*.
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
#include "core/rendering/menu.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/input/key_config.hpp"
|
||||
#include "core/locale/locale.hpp"
|
||||
#include "core/rendering/overlay.hpp"
|
||||
#include "core/rendering/screen.hpp"
|
||||
#include "core/rendering/text.hpp"
|
||||
#include "core/system/director.hpp"
|
||||
#include "game/defines.hpp"
|
||||
#include "game/options.hpp"
|
||||
#include "utils/easing.hpp"
|
||||
#include "version.h"
|
||||
|
||||
namespace Menu {
|
||||
|
||||
@@ -35,174 +41,266 @@ namespace Menu {
|
||||
static constexpr int ITEM_SPACING = 11;
|
||||
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 SUBTITLE_H = 8 + 3; // línia de subtítol + gap
|
||||
|
||||
// --- Animació ---
|
||||
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 ---
|
||||
enum class ItemKind { Toggle,
|
||||
Cycle,
|
||||
IntRange,
|
||||
Submenu,
|
||||
KeyBind };
|
||||
enum class ItemKind : std::uint8_t { TOGGLE,
|
||||
CYCLE,
|
||||
INT_RANGE,
|
||||
SUBMENU,
|
||||
KEY_BIND,
|
||||
ACTION };
|
||||
|
||||
struct Item {
|
||||
const char* label;
|
||||
ItemKind kind;
|
||||
std::function<std::string()> getValue; // opcional
|
||||
std::function<void(int dir)> change; // per Toggle/Cycle/IntRange
|
||||
std::function<void()> enter; // per Submenu
|
||||
SDL_Scancode* scancode{nullptr}; // per KeyBind
|
||||
std::function<std::string()> get_value; // opcional
|
||||
std::function<void(int dir)> change; // per TOGGLE/CYCLE/INT_RANGE
|
||||
std::function<void()> enter; // per SUBMENU i ACTION
|
||||
SDL_Scancode* scancode{nullptr}; // per KEY_BIND
|
||||
std::function<bool()> visible{nullptr}; // nullptr ⇒ sempre visible
|
||||
};
|
||||
|
||||
struct Page {
|
||||
const char* title;
|
||||
std::vector<Item> items;
|
||||
int cursor{0};
|
||||
std::string subtitle; // opcional — si no buit, es dibuixa sota el títol
|
||||
};
|
||||
|
||||
static auto isVisible(const Item& it) -> bool { 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 auto nextVisibleCursor(const Page& p, int from, int dir) -> int {
|
||||
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 ---
|
||||
static std::vector<Page> stack_;
|
||||
static std::unique_ptr<Text> font_;
|
||||
static float open_anim_{0.0F}; // 0 = tancat, 1 = obert
|
||||
static Uint32 last_ticks_{0};
|
||||
static SDL_Scancode* capturing_{nullptr}; // != null → esperant tecla per assignar
|
||||
static std::vector<Page> stack;
|
||||
static std::unique_ptr<Text> font;
|
||||
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 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 ---
|
||||
static constexpr float TRANSITION_SPEED = 5.5F; // ~180 ms
|
||||
static Page transition_outgoing_{"", {}, 0};
|
||||
static bool transition_active_{false};
|
||||
static float transition_progress_{1.0F};
|
||||
static int transition_dir_{+1}; // +1 endavant, -1 enrere
|
||||
static Page transition_outgoing{.title = "", .items = {}, .cursor = 0, .subtitle = ""};
|
||||
static bool transition_active{false};
|
||||
static float transition_progress{1.0F};
|
||||
static int transition_dir{+1}; // +1 endavant, -1 enrere
|
||||
|
||||
// Helpers per triggerar transicions
|
||||
static void pushPage(Page newPage) {
|
||||
transition_outgoing_ = stack_.back();
|
||||
stack_.push_back(std::move(newPage));
|
||||
transition_active_ = true;
|
||||
transition_progress_ = 0.0F;
|
||||
transition_dir_ = +1;
|
||||
static void pushPage(Page new_page) {
|
||||
transition_outgoing = stack.back();
|
||||
stack.push_back(std::move(new_page));
|
||||
transition_active = true;
|
||||
transition_progress = 0.0F;
|
||||
transition_dir = +1;
|
||||
}
|
||||
static void popPage() {
|
||||
transition_outgoing_ = stack_.back();
|
||||
stack_.pop_back();
|
||||
transition_active_ = true;
|
||||
transition_progress_ = 0.0F;
|
||||
transition_dir_ = -1;
|
||||
transition_outgoing = stack.back();
|
||||
stack.pop_back();
|
||||
transition_active = true;
|
||||
transition_progress = 0.0F;
|
||||
transition_dir = -1;
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
static std::string yesNo(bool b) { return b ? Locale::get("menu.values.yes") : Locale::get("menu.values.no"); }
|
||||
static std::string onOff(bool b) { return b ? Locale::get("menu.values.on") : Locale::get("menu.values.off"); }
|
||||
static auto yesNo(bool b) -> std::string { return b ? Locale::get("menu.values.yes") : Locale::get("menu.values.no"); }
|
||||
static auto onOff(bool b) -> std::string { return b ? Locale::get("menu.values.on") : Locale::get("menu.values.off"); }
|
||||
|
||||
// --- Builders de pàgines ---
|
||||
|
||||
static Page buildVideo();
|
||||
static Page buildAudio();
|
||||
static Page buildControls();
|
||||
static auto buildVideo() -> Page;
|
||||
static auto buildAudio() -> Page;
|
||||
static auto buildControls() -> Page;
|
||||
static auto buildGame() -> Page;
|
||||
static auto buildSystem() -> Page;
|
||||
|
||||
static Page buildRoot() {
|
||||
Page p{Locale::get("menu.titles.root"), {}, 0};
|
||||
p.items.push_back({Locale::get("menu.items.video"), ItemKind::Submenu, nullptr, nullptr, [] { pushPage(buildVideo()); }, 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});
|
||||
static auto buildRoot() -> Page {
|
||||
Page p{.title = Locale::get("menu.titles.root"), .items = {}, .cursor = 0, .subtitle = ""};
|
||||
p.items.push_back({Locale::get("menu.items.video"), ItemKind::SUBMENU, nullptr, nullptr, [] { pushPage(buildVideo()); }, 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.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;
|
||||
}
|
||||
|
||||
static Page buildVideo() {
|
||||
Page p{Locale::get("menu.titles.video"), {}, 0};
|
||||
static auto buildVideo() -> Page {
|
||||
Page p{.title = Locale::get("menu.titles.video"), .items = {}, .cursor = 0, .subtitle = ""};
|
||||
|
||||
p.items.push_back({Locale::get("menu.items.zoom"), ItemKind::IntRange, [] {
|
||||
// Zoom i fullscreen: sense sentit a WASM (el navegador posseix el canvas)
|
||||
#ifndef __EMSCRIPTEN__
|
||||
p.items.push_back({Locale::get("menu.items.zoom"), ItemKind::INT_RANGE, [] {
|
||||
char buf[16];
|
||||
std::snprintf(buf, sizeof(buf), "%dX", Screen::get()->getZoom());
|
||||
return std::string(buf); }, [](int dir) {
|
||||
if (dir < 0) Screen::get()->decZoom();
|
||||
else if (dir > 0) Screen::get()->incZoom(); }, nullptr});
|
||||
if (dir < 0) { Screen::get()->decZoom();
|
||||
} 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::INT_RANGE, [] {
|
||||
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});
|
||||
|
||||
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();
|
||||
else Screen::get()->nextShaderType(); }, 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.preset"), ItemKind::Cycle, [] { return std::string(Screen::get()->getCurrentPresetName()); }, [](int dir) {
|
||||
if (dir < 0) Screen::get()->prevPreset();
|
||||
else Screen::get()->nextPreset(); }, nullptr});
|
||||
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();
|
||||
} else { Screen::get()->nextShaderType();
|
||||
} }, 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.preset"), ItemKind::CYCLE, [] { return std::string(Screen::get()->getCurrentPresetName()); }, [](int dir) {
|
||||
if (dir < 0) { Screen::get()->prevPreset();
|
||||
} else { Screen::get()->nextPreset();
|
||||
} }, nullptr, nullptr, [] { return Options::video.shader_enabled; }});
|
||||
|
||||
p.items.push_back({Locale::get("menu.items.render_info"), ItemKind::Cycle, [] {
|
||||
#endif
|
||||
|
||||
// Informació de render
|
||||
p.items.push_back({Locale::get("menu.items.render_info"), ItemKind::CYCLE, [] {
|
||||
switch (Options::render_info.position) {
|
||||
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::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;
|
||||
}
|
||||
|
||||
// Converteix volum 0..1 a percentatge i ho formata com "50%"
|
||||
static std::string volPct(float v) {
|
||||
int pct = static_cast<int>(v * 100.0F + 0.5F);
|
||||
if (pct < 0) pct = 0;
|
||||
if (pct > 100) pct = 100;
|
||||
static auto volPct(float v) -> std::string {
|
||||
int pct = static_cast<int>(std::lround(v * 100.0F));
|
||||
pct = std::max(pct, 0);
|
||||
pct = std::min(pct, 100);
|
||||
char buf[8];
|
||||
std::snprintf(buf, sizeof(buf), "%d%%", pct);
|
||||
return std::string(buf);
|
||||
return {buf};
|
||||
}
|
||||
|
||||
// Canvi +/- d'un volum en steps de 0.05 (5%) amb clamping
|
||||
static void stepVolume(float& v, int dir) {
|
||||
v += (dir >= 0 ? 0.05F : -0.05F);
|
||||
if (v < 0.0F) v = 0.0F;
|
||||
if (v > 1.0F) v = 1.0F;
|
||||
v = std::max(v, 0.0F);
|
||||
v = std::min(v, 1.0F);
|
||||
Options::applyAudio();
|
||||
}
|
||||
|
||||
static Page buildControls() {
|
||||
Page p{Locale::get("menu.titles.controls"), {}, 0};
|
||||
p.items.push_back({Locale::get("menu.items.move_up"), ItemKind::KeyBind, nullptr, nullptr, nullptr, &Options::keys_game.up});
|
||||
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_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});
|
||||
static auto buildControls() -> Page {
|
||||
Page p{.title = Locale::get("menu.titles.controls"), .items = {}, .cursor = 0, .subtitle = ""};
|
||||
p.items.push_back({Locale::get("menu.items.move_up"), ItemKind::KEY_BIND, nullptr, nullptr, nullptr, &Options::keys_game.up});
|
||||
p.items.push_back({Locale::get("menu.items.move_down"), ItemKind::KEY_BIND, nullptr, nullptr, nullptr, &Options::keys_game.down});
|
||||
p.items.push_back({Locale::get("menu.items.move_left"), ItemKind::KEY_BIND, nullptr, nullptr, nullptr, &Options::keys_game.left});
|
||||
p.items.push_back({Locale::get("menu.items.move_right"), ItemKind::KEY_BIND, nullptr, nullptr, nullptr, &Options::keys_game.right});
|
||||
p.items.push_back({Locale::get("menu.items.menu_key"), ItemKind::KEY_BIND, nullptr, nullptr, nullptr, KeyConfig::scancodePtr("menu_toggle")});
|
||||
return p;
|
||||
}
|
||||
|
||||
static Page buildAudio() {
|
||||
Page p{Locale::get("menu.titles.audio"), {}, 0};
|
||||
static auto buildAudio() -> Page {
|
||||
Page p{.title = Locale::get("menu.titles.audio"), .items = {}, .cursor = 0, .subtitle = ""};
|
||||
|
||||
p.items.push_back({Locale::get("menu.items.master_enable"), ItemKind::Toggle, [] { return onOff(Options::audio.enabled); }, [](int) {
|
||||
p.items.push_back({Locale::get("menu.items.master_enable"), ItemKind::TOGGLE, [] { return onOff(Options::audio.enabled); }, [](int) {
|
||||
Options::audio.enabled = !Options::audio.enabled;
|
||||
Options::applyAudio(); }, 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.master_volume"), ItemKind::INT_RANGE, [] { 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) {
|
||||
Options::audio.music_enabled = !Options::audio.music_enabled;
|
||||
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::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::INT_RANGE, [] { 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) {
|
||||
Options::audio.sound_enabled = !Options::audio.sound_enabled;
|
||||
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::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::INT_RANGE, [] { return volPct(Options::audio.sound.volume); }, [](int dir) { stepVolume(Options::audio.sound.volume, dir); }, nullptr});
|
||||
|
||||
return p;
|
||||
}
|
||||
|
||||
static auto buildGame() -> Page {
|
||||
Page p{.title = Locale::get("menu.titles.game"), .items = {}, .cursor = 0, .subtitle = ""};
|
||||
|
||||
p.items.push_back({Locale::get("menu.items.use_new_logo"), ItemKind::TOGGLE, [] { return yesNo(Options::game.use_new_logo); }, [](int) { Options::game.use_new_logo = !Options::game.use_new_logo; }, 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 auto buildSystem() -> Page {
|
||||
Page p{.title = Locale::get("menu.titles.system"), .items = {}, .cursor = 0, .subtitle = ""};
|
||||
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;
|
||||
}
|
||||
@@ -211,34 +309,42 @@ namespace Menu {
|
||||
|
||||
// Alpha blending per pixel sobre el buffer ARGB (ABGR en memòria)
|
||||
static void blendRect(Uint32* buf, int x, int y, int w, int h, Uint32 src_argb, Uint8 src_alpha) {
|
||||
const Uint8 sa = src_alpha;
|
||||
const Uint8 sr = src_argb & 0xFF;
|
||||
const Uint8 sg = (src_argb >> 8) & 0xFF;
|
||||
const Uint8 sb = (src_argb >> 16) & 0xFF;
|
||||
const Uint8 inv = 255 - sa;
|
||||
const Uint8 SA = src_alpha;
|
||||
const Uint8 SR = src_argb & 0xFF;
|
||||
const Uint8 SG = (src_argb >> 8) & 0xFF;
|
||||
const Uint8 SB = (src_argb >> 16) & 0xFF;
|
||||
const Uint8 INV = 255 - SA;
|
||||
for (int row = y; row < y + h; row++) {
|
||||
if (row < 0 || row >= SCREEN_H) continue;
|
||||
if (row < 0 || row >= SCREEN_H) {
|
||||
continue;
|
||||
}
|
||||
for (int col = x; col < x + w; col++) {
|
||||
if (col < 0 || col >= SCREEN_W) continue;
|
||||
Uint32* p = &buf[col + row * SCREEN_W];
|
||||
if (col < 0 || col >= SCREEN_W) {
|
||||
continue;
|
||||
}
|
||||
Uint32* p = &buf[col + (row * SCREEN_W)];
|
||||
Uint32 dst = *p;
|
||||
Uint8 dr = dst & 0xFF;
|
||||
Uint8 dg = (dst >> 8) & 0xFF;
|
||||
Uint8 db = (dst >> 16) & 0xFF;
|
||||
Uint8 r = (sr * sa + dr * inv) / 255;
|
||||
Uint8 g = (sg * sa + dg * inv) / 255;
|
||||
Uint8 b = (sb * sa + db * inv) / 255;
|
||||
*p = 0xFF000000u | (static_cast<Uint32>(b) << 16) | (static_cast<Uint32>(g) << 8) | r;
|
||||
Uint8 r = (SR * SA + dr * INV) / 255;
|
||||
Uint8 g = (SG * SA + dg * INV) / 255;
|
||||
Uint8 b = (SB * SA + db * INV) / 255;
|
||||
*p = 0xFF000000U | (static_cast<Uint32>(b) << 16) | (static_cast<Uint32>(g) << 8) | r;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void fillRect(Uint32* buf, int x, int y, int w, int h, Uint32 color) {
|
||||
for (int row = y; row < y + h; row++) {
|
||||
if (row < 0 || row >= SCREEN_H) continue;
|
||||
if (row < 0 || row >= SCREEN_H) {
|
||||
continue;
|
||||
}
|
||||
for (int col = x; col < x + w; col++) {
|
||||
if (col < 0 || col >= SCREEN_W) continue;
|
||||
buf[col + row * SCREEN_W] = color;
|
||||
if (col < 0 || col >= SCREEN_W) {
|
||||
continue;
|
||||
}
|
||||
buf[col + (row * SCREEN_W)] = color;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -250,129 +356,185 @@ namespace Menu {
|
||||
fillRect(buf, x + w - 1, y, 1, h, color);
|
||||
}
|
||||
|
||||
// Mida final de la caixa segons el nombre d'items
|
||||
static int boxHeight(const Page& page) {
|
||||
int n = static_cast<int>(page.items.size());
|
||||
int body = (n == 0) ? ITEM_SPACING : n * ITEM_SPACING;
|
||||
return HEADER_H + body + BOTTOM_PAD;
|
||||
// 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 auto boxHeight(const Page& page) -> int {
|
||||
const int N = static_cast<int>(std::count_if(page.items.begin(), page.items.end(), [](const auto& it) { return isVisible(it); }));
|
||||
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 ---
|
||||
|
||||
void init() {
|
||||
font_ = std::make_unique<Text>("fonts/8bithud.fnt", "fonts/8bithud.gif");
|
||||
stack_.clear();
|
||||
open_anim_ = 0.0F;
|
||||
last_ticks_ = SDL_GetTicks();
|
||||
font = std::make_unique<Text>("fonts/8bithud.fnt", "fonts/8bithud.gif");
|
||||
stack.clear();
|
||||
open_anim = 0.0F;
|
||||
closing = false;
|
||||
last_ticks = SDL_GetTicks();
|
||||
}
|
||||
|
||||
void destroy() {
|
||||
font_.reset();
|
||||
stack_.clear();
|
||||
font.reset();
|
||||
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 {
|
||||
return !stack_.empty();
|
||||
return !stack.empty() && !closing;
|
||||
}
|
||||
|
||||
// "Visible": encara hi ha caixa per pintar (incloent close animation).
|
||||
auto isVisible() -> bool {
|
||||
return !stack.empty();
|
||||
}
|
||||
|
||||
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()) {
|
||||
close();
|
||||
} else {
|
||||
stack_.push_back(buildRoot());
|
||||
open_anim_ = 0.0F;
|
||||
last_ticks_ = SDL_GetTicks();
|
||||
stack.push_back(buildRoot());
|
||||
open_anim = 0.0F;
|
||||
closing = false;
|
||||
animated_h = static_cast<float>(boxHeight(stack.back()));
|
||||
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() {
|
||||
stack_.clear();
|
||||
open_anim_ = 0.0F;
|
||||
capturing_ = nullptr;
|
||||
transition_active_ = false;
|
||||
transition_progress_ = 1.0F;
|
||||
if (stack.empty() || closing) {
|
||||
return;
|
||||
}
|
||||
closing = true;
|
||||
capturing = nullptr;
|
||||
transition_active = false;
|
||||
transition_progress = 1.0F;
|
||||
last_ticks = SDL_GetTicks();
|
||||
}
|
||||
|
||||
auto isCapturing() -> bool {
|
||||
return capturing_ != nullptr;
|
||||
return capturing != nullptr;
|
||||
}
|
||||
|
||||
void captureKey(SDL_Scancode sc) {
|
||||
if (!capturing_) return;
|
||||
if (capturing == nullptr) {
|
||||
return;
|
||||
}
|
||||
if (sc == SDL_SCANCODE_ESCAPE) {
|
||||
// Cancel·la
|
||||
capturing_ = nullptr;
|
||||
capturing = nullptr;
|
||||
return;
|
||||
}
|
||||
*capturing_ = sc;
|
||||
capturing_ = nullptr;
|
||||
*capturing = sc;
|
||||
capturing = nullptr;
|
||||
}
|
||||
|
||||
void handleKey(SDL_Scancode sc) {
|
||||
if (!isOpen()) return;
|
||||
Page& page = stack_.back();
|
||||
if (page.items.empty()) {
|
||||
// Pàgina buida — només backspace surt
|
||||
if (sc == SDL_SCANCODE_BACKSPACE) {
|
||||
if (stack_.size() > 1)
|
||||
static void backOrClose() {
|
||||
if (stack.size() > 1) {
|
||||
popPage();
|
||||
else
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const int n = static_cast<int>(page.items.size());
|
||||
|
||||
// Activació d'un ítem (RETURN/KP_ENTER): SUBMENU/ACTION criden enter,
|
||||
// KEY_BIND inicia captura, la resta avança change(+1).
|
||||
static void activateItem(Item& item) {
|
||||
if (item.kind == ItemKind::SUBMENU || item.kind == ItemKind::ACTION) {
|
||||
if (item.enter) {
|
||||
item.enter();
|
||||
}
|
||||
} else if (item.kind == ItemKind::KEY_BIND) {
|
||||
capturing = item.scancode;
|
||||
} else if (item.change) {
|
||||
item.change(+1);
|
||||
}
|
||||
}
|
||||
|
||||
static void applyKeyToItem(Page& page, SDL_Scancode sc) {
|
||||
Item& item = page.items[page.cursor];
|
||||
switch (sc) {
|
||||
case SDL_SCANCODE_UP:
|
||||
page.cursor = (page.cursor - 1 + n) % n;
|
||||
page.cursor = nextVisibleCursor(page, page.cursor, -1);
|
||||
break;
|
||||
case SDL_SCANCODE_DOWN:
|
||||
page.cursor = (page.cursor + 1) % n;
|
||||
page.cursor = nextVisibleCursor(page, page.cursor, +1);
|
||||
break;
|
||||
case SDL_SCANCODE_LEFT:
|
||||
if (page.items[page.cursor].kind != ItemKind::Submenu &&
|
||||
page.items[page.cursor].change) {
|
||||
page.items[page.cursor].change(-1);
|
||||
if (item.kind != ItemKind::SUBMENU && item.change) {
|
||||
item.change(-1);
|
||||
}
|
||||
break;
|
||||
case SDL_SCANCODE_RIGHT:
|
||||
if (page.items[page.cursor].kind != ItemKind::Submenu &&
|
||||
page.items[page.cursor].change) {
|
||||
page.items[page.cursor].change(+1);
|
||||
if (item.kind != ItemKind::SUBMENU && item.change) {
|
||||
item.change(+1);
|
||||
}
|
||||
break;
|
||||
case SDL_SCANCODE_RETURN:
|
||||
case SDL_SCANCODE_KP_ENTER:
|
||||
if (page.items[page.cursor].kind == ItemKind::Submenu) {
|
||||
if (page.items[page.cursor].enter) page.items[page.cursor].enter();
|
||||
} else if (page.items[page.cursor].kind == ItemKind::KeyBind) {
|
||||
capturing_ = page.items[page.cursor].scancode;
|
||||
} else if (page.items[page.cursor].change) {
|
||||
page.items[page.cursor].change(+1);
|
||||
}
|
||||
activateItem(item);
|
||||
break;
|
||||
case SDL_SCANCODE_BACKSPACE:
|
||||
if (stack_.size() > 1)
|
||||
popPage();
|
||||
else
|
||||
close();
|
||||
backOrClose();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void handleKey(SDL_Scancode sc) {
|
||||
if (!isOpen()) {
|
||||
return;
|
||||
}
|
||||
Page& page = stack.back();
|
||||
if (page.items.empty()) {
|
||||
if (sc == SDL_SCANCODE_BACKSPACE) {
|
||||
backOrClose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!isVisible(page.items[page.cursor])) {
|
||||
page.cursor = nextVisibleCursor(page, page.cursor, +1);
|
||||
}
|
||||
applyKeyToItem(page, sc);
|
||||
// Defensa: si una acció ha amagat l'ítem que tenim sota el cursor,
|
||||
// saltem al pròxim visible.
|
||||
if (!stack.empty()) {
|
||||
Page& top = stack.back();
|
||||
if (!top.items.empty() && !isVisible(top.items[top.cursor])) {
|
||||
top.cursor = nextVisibleCursor(top, top.cursor, +1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Forward decl: renderOneItem viu sota renderPageContent però aquest l'ha de cridar.
|
||||
static void renderOneItem(Uint32* pixel_data, const Item& item, bool selected, int box_x, int y, int x_offset, int clip_x_min, int clip_x_max, int clip_y_min, int clip_y_max);
|
||||
|
||||
// Dibuixa el contingut d'una pàgina amb un offset horitzontal i un rang de clip.
|
||||
// box_x/box_y són les coordenades de la caixa (per calcular posicions relatives);
|
||||
// clip_x_min/clip_x_max limiten on es dibuixa text i la línia separadora.
|
||||
static void renderPageContent(Uint32* pixel_data, const Page& page, int box_x, int box_y, int x_offset, int clip_x_min, int clip_x_max, int clip_y_min, int clip_y_max) {
|
||||
// Títol
|
||||
int title_w = font_->width(page.title);
|
||||
int title_x = box_x + (BOX_W - title_w) / 2 + x_offset;
|
||||
font_->drawClipped(pixel_data, title_x, box_y + TITLE_PAD_Y, page.title, TITLE_COLOR, clip_x_min, clip_x_max, clip_y_min, clip_y_max);
|
||||
int title_w = font->width(page.title);
|
||||
int title_x = box_x + ((BOX_W - title_w) / 2) + x_offset;
|
||||
font->drawClipped(pixel_data, title_x, box_y + TITLE_PAD_Y, page.title, TITLE_COLOR, clip_x_min, clip_x_max, clip_y_min, clip_y_max);
|
||||
|
||||
// Línia sota el títol (també lliscada) — clippada manualment
|
||||
int title_line_y = box_y + TITLE_PAD_Y + font_->charHeight() + 2;
|
||||
int title_line_y = box_y + TITLE_PAD_Y + font->charHeight() + 2;
|
||||
if (title_line_y >= clip_y_min && title_line_y < clip_y_max) {
|
||||
int line_x = box_x + 4 + x_offset;
|
||||
int line_w = BOX_W - 8;
|
||||
@@ -383,104 +545,189 @@ 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;
|
||||
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)
|
||||
const int VISIBLE_COUNT = static_cast<int>(std::count_if(page.items.begin(), page.items.end(), [](const auto& it) { return isVisible(it); }));
|
||||
if (VISIBLE_COUNT == 0) {
|
||||
const char* empty_text = Locale::get("menu.values.empty");
|
||||
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);
|
||||
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);
|
||||
return;
|
||||
}
|
||||
|
||||
int y_slot = 0;
|
||||
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];
|
||||
if (!isVisible(item)) {
|
||||
continue;
|
||||
}
|
||||
const int Y = items_y + (y_slot * ITEM_SPACING);
|
||||
++y_slot;
|
||||
const bool SELECTED = (static_cast<int>(i) == page.cursor);
|
||||
renderOneItem(pixel_data, item, SELECTED, box_x, Y, x_offset, clip_x_min, clip_x_max, clip_y_min, clip_y_max);
|
||||
}
|
||||
}
|
||||
|
||||
static auto keybindText(const Item& item, bool this_capturing) -> const char* {
|
||||
const char* text = nullptr;
|
||||
if (this_capturing) {
|
||||
text = Locale::get("menu.values.press_key");
|
||||
} else if (item.scancode != nullptr) {
|
||||
text = SDL_GetScancodeName(*item.scancode);
|
||||
} else {
|
||||
text = "";
|
||||
}
|
||||
if ((text == nullptr) || (*text == 0)) {
|
||||
text = Locale::get("menu.values.unknown");
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
static void renderActionItem(Uint32* pixel_data, const Item& item, bool selected, int box_x, int y, int x_offset, int clip_x_min, int clip_x_max, int clip_y_min, int clip_y_max) {
|
||||
const Uint32 LABEL_COLOR_LOCAL = selected ? CURSOR_COLOR : LABEL_COLOR;
|
||||
const int LW = font->width(item.label);
|
||||
const int LX = box_x + ((BOX_W - LW) / 2) + x_offset;
|
||||
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, 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_LOCAL, 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);
|
||||
static auto keybindColor(bool this_capturing, bool selected) -> Uint32 {
|
||||
if (this_capturing) {
|
||||
return 0xFF00FFFF;
|
||||
}
|
||||
return selected ? CURSOR_COLOR : VALUE_COLOR;
|
||||
}
|
||||
|
||||
if (item.kind == ItemKind::Submenu) {
|
||||
static void renderItemValue(Uint32* pixel_data, const Item& item, bool selected, int box_x, int y, int x_offset, int clip_x_min, int clip_x_max, int clip_y_min, int clip_y_max) {
|
||||
if (item.kind == ItemKind::SUBMENU) {
|
||||
const char* arrow = ">>";
|
||||
int aw = font_->width(arrow);
|
||||
Uint32 ac = selected ? CURSOR_COLOR : VALUE_COLOR;
|
||||
font_->drawClipped(pixel_data, box_x + BOX_W - ITEM_PAD_X - aw + x_offset, y, arrow, ac, clip_x_min, clip_x_max, clip_y_min, clip_y_max);
|
||||
} else if (item.kind == ItemKind::KeyBind) {
|
||||
bool this_capturing = (capturing_ == item.scancode);
|
||||
const char* text = this_capturing ? Locale::get("menu.values.press_key") : (item.scancode ? SDL_GetScancodeName(*item.scancode) : "");
|
||||
if (!text || !*text) text = Locale::get("menu.values.unknown");
|
||||
int tw = font_->width(text);
|
||||
Uint32 tc = this_capturing ? 0xFF00FFFF : (selected ? CURSOR_COLOR : VALUE_COLOR);
|
||||
font_->drawClipped(pixel_data, box_x + BOX_W - ITEM_PAD_X - tw + x_offset, y, text, tc, clip_x_min, clip_x_max, clip_y_min, clip_y_max);
|
||||
} else if (item.getValue) {
|
||||
std::string value = item.getValue();
|
||||
int value_w = font_->width(value.c_str());
|
||||
Uint32 value_color = selected ? CURSOR_COLOR : VALUE_COLOR;
|
||||
font_->drawClipped(pixel_data, box_x + BOX_W - ITEM_PAD_X - value_w + x_offset, y, value.c_str(), value_color, clip_x_min, clip_x_max, clip_y_min, clip_y_max);
|
||||
const int AW = font->width(arrow);
|
||||
const Uint32 AC = selected ? CURSOR_COLOR : VALUE_COLOR;
|
||||
font->drawClipped(pixel_data, box_x + BOX_W - ITEM_PAD_X - AW + x_offset, y, arrow, AC, clip_x_min, clip_x_max, clip_y_min, clip_y_max);
|
||||
return;
|
||||
}
|
||||
if (item.kind == ItemKind::KEY_BIND) {
|
||||
const bool THIS_CAPTURING = (capturing == item.scancode);
|
||||
const char* text = keybindText(item, THIS_CAPTURING);
|
||||
const int TW = font->width(text);
|
||||
const Uint32 TC = keybindColor(THIS_CAPTURING, selected);
|
||||
font->drawClipped(pixel_data, box_x + BOX_W - ITEM_PAD_X - TW + x_offset, y, text, TC, clip_x_min, clip_x_max, clip_y_min, clip_y_max);
|
||||
return;
|
||||
}
|
||||
if (item.get_value) {
|
||||
const std::string VALUE = item.get_value();
|
||||
const int VALUE_W = font->width(VALUE.c_str());
|
||||
const Uint32 VALUE_COLOR_LOCAL = selected ? CURSOR_COLOR : VALUE_COLOR;
|
||||
font->drawClipped(pixel_data, box_x + BOX_W - ITEM_PAD_X - VALUE_W + x_offset, y, VALUE.c_str(), VALUE_COLOR_LOCAL, clip_x_min, clip_x_max, clip_y_min, clip_y_max);
|
||||
}
|
||||
}
|
||||
|
||||
static void renderOneItem(Uint32* pixel_data, const Item& item, bool selected, int box_x, int y, int x_offset, int clip_x_min, int clip_x_max, int clip_y_min, int clip_y_max) {
|
||||
if (item.kind == ItemKind::ACTION) {
|
||||
renderActionItem(pixel_data, item, selected, box_x, y, x_offset, clip_x_min, clip_x_max, clip_y_min, clip_y_max);
|
||||
return;
|
||||
}
|
||||
const Uint32 LABEL_COLOR_LOCAL = selected ? CURSOR_COLOR : LABEL_COLOR;
|
||||
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 + ITEM_PAD_X + x_offset, y, item.label, LABEL_COLOR_LOCAL, clip_x_min, clip_x_max, clip_y_min, clip_y_max);
|
||||
renderItemValue(pixel_data, item, selected, box_x, y, x_offset, clip_x_min, clip_x_max, clip_y_min, clip_y_max);
|
||||
}
|
||||
|
||||
void render(Uint32* pixel_data) {
|
||||
if (!isOpen() || !font_ || !pixel_data) return;
|
||||
if (!isVisible() || !font || (pixel_data == nullptr)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Delta time
|
||||
Uint32 now = SDL_GetTicks();
|
||||
float dt = static_cast<float>(now - last_ticks_) / 1000.0F;
|
||||
last_ticks_ = now;
|
||||
if (open_anim_ < 1.0F) {
|
||||
open_anim_ += OPEN_SPEED * dt;
|
||||
if (open_anim_ > 1.0F) open_anim_ = 1.0F;
|
||||
float dt = static_cast<float>(now - last_ticks) / 1000.0F;
|
||||
last_ticks = now;
|
||||
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 = std::min(open_anim, 1.0F);
|
||||
}
|
||||
|
||||
// Avança transició
|
||||
if (transition_active_) {
|
||||
transition_progress_ += TRANSITION_SPEED * dt;
|
||||
if (transition_progress_ >= 1.0F) {
|
||||
transition_progress_ = 1.0F;
|
||||
transition_active_ = false;
|
||||
if (transition_active) {
|
||||
transition_progress += TRANSITION_SPEED * dt;
|
||||
if (transition_progress >= 1.0F) {
|
||||
transition_progress = 1.0F;
|
||||
transition_active = false;
|
||||
}
|
||||
}
|
||||
|
||||
const Page& page = stack_.back();
|
||||
const int current_h = boxHeight(page);
|
||||
const Page& page = stack.back();
|
||||
const int CURRENT_H = boxHeight(page);
|
||||
|
||||
float eased = Easing::outQuad(open_anim_);
|
||||
// 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;
|
||||
t = std::min(t, 1.0F);
|
||||
animated_h += diff * t;
|
||||
}
|
||||
}
|
||||
|
||||
float eased = Easing::outQuad(open_anim);
|
||||
|
||||
// Calcula alçada (amb transició si escau)
|
||||
int target_h = current_h;
|
||||
if (transition_active_) {
|
||||
int outgoing_h = boxHeight(transition_outgoing_);
|
||||
float tp = Easing::outQuad(transition_progress_);
|
||||
target_h = Easing::lerpInt(outgoing_h, current_h, tp);
|
||||
int target_h = static_cast<int>(animated_h);
|
||||
if (transition_active) {
|
||||
int outgoing_h = boxHeight(transition_outgoing);
|
||||
float tp = Easing::outQuad(transition_progress);
|
||||
target_h = Easing::lerpInt(outgoing_h, static_cast<int>(animated_h), tp);
|
||||
}
|
||||
|
||||
// Caixa creix verticalment durant l'obertura
|
||||
int box_h = static_cast<int>(target_h * eased);
|
||||
if (box_h < 2) box_h = 2;
|
||||
box_h = std::max(box_h, 2);
|
||||
int box_x = (SCREEN_W - BOX_W) / 2;
|
||||
int box_y = (SCREEN_H - box_h) / 2;
|
||||
|
||||
// Fons semi-transparent (alpha escalat per l'animació d'obertura)
|
||||
Uint8 alpha = static_cast<Uint8>(BG_ALPHA * eased);
|
||||
auto alpha = static_cast<Uint8>(BG_ALPHA * eased);
|
||||
blendRect(pixel_data, box_x, box_y, BOX_W, box_h, BG_COLOR, alpha);
|
||||
|
||||
// El contingut només apareix quan la caixa és prou gran
|
||||
if (open_anim_ >= 0.9F) {
|
||||
if (open_anim >= 0.9F) {
|
||||
int clip_x_min = box_x + 1;
|
||||
int clip_x_max = box_x + BOX_W - 1;
|
||||
int clip_y_min = box_y + 1;
|
||||
int clip_y_max = box_y + box_h - 1;
|
||||
|
||||
if (transition_active_) {
|
||||
float tp = Easing::outQuad(transition_progress_);
|
||||
int out_offset = static_cast<int>(-transition_dir_ * BOX_W * tp);
|
||||
int new_offset = static_cast<int>(transition_dir_ * BOX_W * (1.0F - tp));
|
||||
renderPageContent(pixel_data, transition_outgoing_, box_x, box_y, out_offset, clip_x_min, clip_x_max, clip_y_min, clip_y_max);
|
||||
if (transition_active) {
|
||||
float tp = Easing::outQuad(transition_progress);
|
||||
int out_offset = static_cast<int>(-transition_dir * BOX_W * tp);
|
||||
int new_offset = static_cast<int>(transition_dir * BOX_W * (1.0F - tp));
|
||||
renderPageContent(pixel_data, transition_outgoing, box_x, box_y, out_offset, clip_x_min, clip_x_max, clip_y_min, clip_y_max);
|
||||
renderPageContent(pixel_data, page, box_x, box_y, new_offset, clip_x_min, clip_x_max, clip_y_min, clip_y_max);
|
||||
} else {
|
||||
renderPageContent(pixel_data, page, box_x, box_y, 0, clip_x_min, clip_x_max, clip_y_min, clip_y_max);
|
||||
|
||||
@@ -6,11 +6,15 @@ namespace Menu {
|
||||
void init();
|
||||
void destroy();
|
||||
|
||||
// "Actiu": el menú accepta input. Fals durant l'animació de tancament.
|
||||
[[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 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);
|
||||
|
||||
// Gestió d'input — cridat des del Director en KEY_DOWN
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
namespace Overlay {
|
||||
|
||||
static std::unique_ptr<Text> font_;
|
||||
static std::unique_ptr<Text> font;
|
||||
|
||||
// --- Aspecte de la notificació ---
|
||||
static constexpr Uint32 NOTIF_BG_COLOR = 0xFF2E1A1A; // Fons blau fosc (ABGR)
|
||||
@@ -33,7 +33,7 @@ namespace Overlay {
|
||||
|
||||
// --- Estat de les notificacions ---
|
||||
|
||||
enum class Status { RISING,
|
||||
enum class Status : std::uint8_t { RISING,
|
||||
STAY,
|
||||
VANISHING,
|
||||
FINISHED };
|
||||
@@ -52,12 +52,12 @@ namespace Overlay {
|
||||
int box_h{0}; // Alçada de la caixa (calculat al crear)
|
||||
};
|
||||
|
||||
static std::vector<Notification> notifications_;
|
||||
static Uint32 last_ticks_ = 0;
|
||||
static std::vector<Notification> notifications;
|
||||
static Uint32 last_ticks = 0;
|
||||
|
||||
// --- Render info ---
|
||||
static Options::RenderInfoPosition info_visible_pos_ = Options::RenderInfoPosition::OFF;
|
||||
static float info_anim_ = 0.0F; // 0 = fora de pantalla, 1 = posició final
|
||||
static Options::RenderInfoPosition info_visible_pos = Options::RenderInfoPosition::OFF;
|
||||
static float info_anim = 0.0F; // 0 = fora de pantalla, 1 = posició final
|
||||
static constexpr float INFO_SLIDE_SPEED = 5.0F;
|
||||
|
||||
// Segments del render info — cadascú amb la seva pròpia visibilitat animada
|
||||
@@ -69,17 +69,17 @@ namespace Overlay {
|
||||
bool visible{false};
|
||||
bool mono_digits{false}; // si true, dígits amb amplada fixa (la resta natural)
|
||||
};
|
||||
static InfoSegment info_segments_[INFO_SEGMENT_COUNT];
|
||||
static InfoSegment info_segments[INFO_SEGMENT_COUNT];
|
||||
|
||||
// --- Crèdits cinematogràfics ---
|
||||
// Usen el sistema de notificacions en posició TOP_CENTER_DROP.
|
||||
enum class CreditsPhase { IDLE,
|
||||
enum class CreditsPhase : std::uint8_t { IDLE,
|
||||
DELAY,
|
||||
PLAYING_1,
|
||||
GAP,
|
||||
PLAYING_2 };
|
||||
static CreditsPhase credits_phase_ = CreditsPhase::IDLE;
|
||||
static float credits_timer_ = 0.0F; // segons dins la phase actual
|
||||
static CreditsPhase credits_phase = CreditsPhase::IDLE;
|
||||
static float credits_timer = 0.0F; // segons dins la phase actual
|
||||
static constexpr float CREDITS_DELAY = 2.0F;
|
||||
static constexpr float CREDITS_GAP = 0.4F;
|
||||
static constexpr float CREDITS_HOLD = 7.5F;
|
||||
@@ -87,39 +87,34 @@ namespace Overlay {
|
||||
static constexpr Uint32 CREDITS_FG = NOTIF_TEXT_COLOR; // mateix cian
|
||||
|
||||
// --- Doble ESC per a eixir ---
|
||||
static bool esc_waiting_ = false;
|
||||
static bool esc_waiting = false;
|
||||
|
||||
void init() {
|
||||
font_ = std::make_unique<Text>("fonts/8bithud.fnt", "fonts/8bithud.gif");
|
||||
last_ticks_ = SDL_GetTicks();
|
||||
font = std::make_unique<Text>("fonts/8bithud.fnt", "fonts/8bithud.gif");
|
||||
last_ticks = SDL_GetTicks();
|
||||
}
|
||||
|
||||
void destroy() {
|
||||
font_.reset();
|
||||
notifications_.clear();
|
||||
font.reset();
|
||||
notifications.clear();
|
||||
}
|
||||
|
||||
// Pinta un rectangle sòlid dins els límits de la pantalla
|
||||
static void drawRect(Uint32* pixel_data, int rx, int ry, int rw, int rh, Uint32 color) {
|
||||
for (int row = ry; row < ry + rh; row++) {
|
||||
if (row < 0 || row >= SCREEN_H) continue;
|
||||
if (row < 0 || row >= SCREEN_H) {
|
||||
continue;
|
||||
}
|
||||
for (int col = rx; col < rx + rw; col++) {
|
||||
if (col < 0 || col >= SCREEN_W) continue;
|
||||
pixel_data[col + row * SCREEN_W] = color;
|
||||
if (col < 0 || col >= SCREEN_W) {
|
||||
continue;
|
||||
}
|
||||
pixel_data[col + (row * SCREEN_W)] = color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void render(Uint32* pixel_data) {
|
||||
if (!font_ || !pixel_data) return;
|
||||
|
||||
// Calcula delta time
|
||||
Uint32 now = SDL_GetTicks();
|
||||
float dt = static_cast<float>(now - last_ticks_) / 1000.0F;
|
||||
last_ticks_ = now;
|
||||
|
||||
// Actualitza i pinta cada notificació
|
||||
for (auto& notif : notifications_) {
|
||||
static void updateNotifFsm(Notification& notif, float dt) {
|
||||
switch (notif.status) {
|
||||
case Status::RISING:
|
||||
notif.anim += SLIDE_SPEED * dt;
|
||||
@@ -129,226 +124,197 @@ namespace Overlay {
|
||||
notif.timer = 0.0F;
|
||||
}
|
||||
break;
|
||||
|
||||
case Status::STAY:
|
||||
notif.timer += dt;
|
||||
if (notif.timer >= notif.duration) {
|
||||
notif.status = Status::VANISHING;
|
||||
}
|
||||
break;
|
||||
|
||||
case Status::VANISHING:
|
||||
notif.anim -= SLIDE_SPEED * dt;
|
||||
if (notif.anim <= 0.0F) {
|
||||
notif.status = Status::FINISHED;
|
||||
}
|
||||
break;
|
||||
|
||||
case Status::FINISHED:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (notif.status == Status::FINISHED) continue;
|
||||
static void computeNotifBoxPos(const Notification& notif, int& box_x, int& box_y) {
|
||||
switch (notif.pos) {
|
||||
case NotifPosition::TOP_LEFT_SLIDE:
|
||||
box_x = NOTIF_MARGIN_X - static_cast<int>((1.0F - notif.anim) * (notif.box_w + NOTIF_MARGIN_X));
|
||||
box_y = NOTIF_MARGIN_Y;
|
||||
break;
|
||||
case NotifPosition::TOP_CENTER_DROP:
|
||||
box_x = (SCREEN_W - notif.box_w) / 2;
|
||||
box_y = NOTIF_MARGIN_Y - static_cast<int>((1.0F - notif.anim) * (notif.box_h + NOTIF_MARGIN_Y));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Posició segons el tipus
|
||||
static void drawNotifTextLine(Uint32* pixel_data, const std::string& line, int line_x, int line_y, const Notification& notif) {
|
||||
if (notif.style == NotifStyle::SHADOW) {
|
||||
font->draw(pixel_data, line_x + 1, line_y + 1, line.c_str(), notif.accent_color);
|
||||
} else if (notif.style == NotifStyle::OUTLINE) {
|
||||
font->draw(pixel_data, line_x, line_y - 1, line.c_str(), notif.accent_color);
|
||||
font->draw(pixel_data, line_x, line_y + 1, line.c_str(), notif.accent_color);
|
||||
font->draw(pixel_data, line_x - 1, line_y, line.c_str(), notif.accent_color);
|
||||
font->draw(pixel_data, line_x + 1, line_y, line.c_str(), notif.accent_color);
|
||||
}
|
||||
font->draw(pixel_data, line_x, line_y, line.c_str(), notif.text_color);
|
||||
}
|
||||
|
||||
static void renderOneNotification(Uint32* pixel_data, const Notification& notif) {
|
||||
int box_x = 0;
|
||||
int box_y = 0;
|
||||
switch (notif.pos) {
|
||||
case NotifPosition::TOP_LEFT_SLIDE: {
|
||||
int target_x = NOTIF_MARGIN_X;
|
||||
int target_y = NOTIF_MARGIN_Y;
|
||||
box_x = target_x - static_cast<int>((1.0F - notif.anim) * (notif.box_w + NOTIF_MARGIN_X));
|
||||
box_y = target_y;
|
||||
break;
|
||||
}
|
||||
case NotifPosition::TOP_CENTER_DROP: {
|
||||
int target_y = NOTIF_MARGIN_Y;
|
||||
box_x = (SCREEN_W - notif.box_w) / 2;
|
||||
// Baixa des de sobre de la pantalla fins a target_y
|
||||
box_y = target_y - static_cast<int>((1.0F - notif.anim) * (notif.box_h + NOTIF_MARGIN_Y));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Pinta fons (si BOX)
|
||||
computeNotifBoxPos(notif, box_x, box_y);
|
||||
if (notif.style == NotifStyle::BOX) {
|
||||
drawRect(pixel_data, box_x, box_y, notif.box_w, notif.box_h, notif.accent_color);
|
||||
}
|
||||
|
||||
// Pinta el text línia a línia (amb ombra o contorn segons style)
|
||||
int line_h = font_->charHeight();
|
||||
const int LINE_H = font->charHeight();
|
||||
int line_y = box_y + NOTIF_PADDING_V;
|
||||
for (const auto& line : notif.lines) {
|
||||
int line_w = font_->width(line.c_str());
|
||||
int line_x = box_x + (notif.box_w - line_w) / 2; // centrat dins la caixa
|
||||
if (notif.style == NotifStyle::SHADOW) {
|
||||
font_->draw(pixel_data, line_x + 1, line_y + 1, line.c_str(), notif.accent_color);
|
||||
} else if (notif.style == NotifStyle::OUTLINE) {
|
||||
// Contorn 4-direccional (N, S, E, W)
|
||||
font_->draw(pixel_data, line_x, line_y - 1, line.c_str(), notif.accent_color);
|
||||
font_->draw(pixel_data, line_x, line_y + 1, line.c_str(), notif.accent_color);
|
||||
font_->draw(pixel_data, line_x - 1, line_y, line.c_str(), notif.accent_color);
|
||||
font_->draw(pixel_data, line_x + 1, line_y, line.c_str(), notif.accent_color);
|
||||
}
|
||||
font_->draw(pixel_data, line_x, line_y, line.c_str(), notif.text_color);
|
||||
line_y += line_h + 1;
|
||||
const int LINE_W = font->width(line.c_str());
|
||||
const int LINE_X = box_x + ((notif.box_w - LINE_W) / 2);
|
||||
drawNotifTextLine(pixel_data, line, LINE_X, line_y, notif);
|
||||
line_y += LINE_H + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Render info (FPS, driver, shader) — animat amb slide vertical
|
||||
// State machine: visible_pos s'actualitza cap a desired quan anim arriba a 0
|
||||
{
|
||||
const auto desired = Options::render_info.position;
|
||||
if (desired == info_visible_pos_) {
|
||||
// Mateix lloc: entra fins a 1
|
||||
if (info_anim_ < 1.0F) {
|
||||
info_anim_ += INFO_SLIDE_SPEED * dt;
|
||||
if (info_anim_ > 1.0F) info_anim_ = 1.0F;
|
||||
static void updateRenderInfoFsm(float dt) {
|
||||
const auto DESIRED = Options::render_info.position;
|
||||
if (DESIRED == info_visible_pos) {
|
||||
if (info_anim < 1.0F) {
|
||||
info_anim = std::min(info_anim + (INFO_SLIDE_SPEED * dt), 1.0F);
|
||||
}
|
||||
} else if (info_visible_pos == Options::RenderInfoPosition::OFF) {
|
||||
info_visible_pos = DESIRED;
|
||||
info_anim = 0.0F;
|
||||
} else {
|
||||
// Canvi: si visible_pos està OFF, commuta directament
|
||||
if (info_visible_pos_ == Options::RenderInfoPosition::OFF) {
|
||||
info_visible_pos_ = desired;
|
||||
info_anim_ = 0.0F;
|
||||
} else {
|
||||
// Ix del lloc actual
|
||||
info_anim_ -= INFO_SLIDE_SPEED * dt;
|
||||
if (info_anim_ <= 0.0F) {
|
||||
info_anim_ = 0.0F;
|
||||
info_visible_pos_ = desired;
|
||||
info_anim -= INFO_SLIDE_SPEED * dt;
|
||||
if (info_anim <= 0.0F) {
|
||||
info_anim = 0.0F;
|
||||
info_visible_pos = DESIRED;
|
||||
}
|
||||
}
|
||||
for (auto& seg : info_segments) {
|
||||
const float TARGET = seg.visible ? 1.0F : 0.0F;
|
||||
if (seg.anim < TARGET) {
|
||||
seg.anim = std::min(seg.anim + (SEG_SPEED * dt), TARGET);
|
||||
} else if (seg.anim > TARGET) {
|
||||
seg.anim = std::max(seg.anim - (SEG_SPEED * dt), TARGET);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Actualitza animacions individuals dels segments
|
||||
for (auto& seg : info_segments_) {
|
||||
float target = seg.visible ? 1.0F : 0.0F;
|
||||
if (seg.anim < target) {
|
||||
seg.anim += SEG_SPEED * dt;
|
||||
if (seg.anim > target) seg.anim = target;
|
||||
} else if (seg.anim > target) {
|
||||
seg.anim -= SEG_SPEED * dt;
|
||||
if (seg.anim < target) seg.anim = target;
|
||||
}
|
||||
}
|
||||
|
||||
// Render si hi ha alguna cosa visible
|
||||
if (info_visible_pos_ != Options::RenderInfoPosition::OFF && info_anim_ > 0.0F) {
|
||||
const int DIGIT_CELL = font_->charBoxWidth() - 1; // amplada uniforme per dígit
|
||||
|
||||
// Calcula amplada total interpolant cada segment per la seva anim
|
||||
static auto computeInfoTotalWidth(int digit_cell) -> float {
|
||||
float total_w = 0.0F;
|
||||
for (auto& seg : info_segments_) {
|
||||
for (const auto& seg : info_segments) {
|
||||
if (seg.anim > 0.0F && !seg.text.empty()) {
|
||||
int w = seg.mono_digits
|
||||
? font_->widthMonoDigits(seg.text.c_str(), DIGIT_CELL)
|
||||
: font_->width(seg.text.c_str());
|
||||
total_w += w * Easing::outQuad(seg.anim);
|
||||
const int W = seg.mono_digits
|
||||
? font->widthMonoDigits(seg.text.c_str(), digit_cell)
|
||||
: font->width(seg.text.c_str());
|
||||
total_w += static_cast<float>(W) * Easing::outQuad(seg.anim);
|
||||
}
|
||||
}
|
||||
if (total_w > 0.0F) {
|
||||
float eased_y = Easing::outQuad(info_anim_);
|
||||
int ch = font_->charHeight();
|
||||
int final_y;
|
||||
int start_y;
|
||||
if (info_visible_pos_ == Options::RenderInfoPosition::TOP) {
|
||||
final_y = 1;
|
||||
start_y = -ch - 1;
|
||||
} else {
|
||||
final_y = SCREEN_H - ch - 1;
|
||||
start_y = SCREEN_H;
|
||||
return total_w;
|
||||
}
|
||||
int info_y = start_y + static_cast<int>((final_y - start_y) * eased_y);
|
||||
|
||||
// Dibuixa cada segment en la seva posició x acumulada
|
||||
float cur_x = (SCREEN_W - total_w) / 2.0F;
|
||||
for (auto& seg : info_segments_) {
|
||||
if (seg.anim > 0.01F && !seg.text.empty()) {
|
||||
int xi = static_cast<int>(cur_x);
|
||||
int seg_w = seg.mono_digits
|
||||
? font_->widthMonoDigits(seg.text.c_str(), DIGIT_CELL)
|
||||
: font_->width(seg.text.c_str());
|
||||
static void drawInfoSegment(Uint32* pixel_data, const InfoSegment& seg, int xi, int info_y, int digit_cell) {
|
||||
if (seg.mono_digits) {
|
||||
font_->drawMonoDigits(pixel_data, xi + 1, info_y + 1, seg.text.c_str(), Options::render_info.shadow_color, DIGIT_CELL);
|
||||
font_->drawMonoDigits(pixel_data, xi, info_y, seg.text.c_str(), Options::render_info.text_color, DIGIT_CELL);
|
||||
font->drawMonoDigits(pixel_data, xi + 1, info_y + 1, seg.text.c_str(), Options::render_info.shadow_color, digit_cell);
|
||||
font->drawMonoDigits(pixel_data, xi, info_y, seg.text.c_str(), Options::render_info.text_color, digit_cell);
|
||||
} else {
|
||||
font_->draw(pixel_data, xi + 1, info_y + 1, seg.text.c_str(), Options::render_info.shadow_color);
|
||||
font_->draw(pixel_data, xi, info_y, seg.text.c_str(), Options::render_info.text_color);
|
||||
}
|
||||
cur_x += seg_w * Easing::outQuad(seg.anim);
|
||||
}
|
||||
}
|
||||
}
|
||||
font->draw(pixel_data, xi + 1, info_y + 1, seg.text.c_str(), Options::render_info.shadow_color);
|
||||
font->draw(pixel_data, xi, info_y, seg.text.c_str(), Options::render_info.text_color);
|
||||
}
|
||||
}
|
||||
|
||||
// Elimina les acabades
|
||||
notifications_.erase(
|
||||
std::remove_if(notifications_.begin(), notifications_.end(), [](const Notification& n) { return n.status == Status::FINISHED; }),
|
||||
notifications_.end());
|
||||
|
||||
// Si la notificació d'ESC ha desaparegut, reseteja l'estat
|
||||
if (esc_waiting_ && notifications_.empty()) {
|
||||
esc_waiting_ = false;
|
||||
static void renderRenderInfo(Uint32* pixel_data) {
|
||||
if (info_visible_pos == Options::RenderInfoPosition::OFF || info_anim <= 0.0F) {
|
||||
return;
|
||||
}
|
||||
const int DIGIT_CELL = font->charBoxWidth() - 1;
|
||||
const float TOTAL_W = computeInfoTotalWidth(DIGIT_CELL);
|
||||
if (TOTAL_W <= 0.0F) {
|
||||
return;
|
||||
}
|
||||
const float EASED_Y = Easing::outQuad(info_anim);
|
||||
const int CH = font->charHeight();
|
||||
const int FINAL_Y = (info_visible_pos == Options::RenderInfoPosition::TOP) ? 1 : SCREEN_H - CH - 1;
|
||||
const int START_Y = (info_visible_pos == Options::RenderInfoPosition::TOP) ? -CH - 1 : SCREEN_H;
|
||||
const int INFO_Y = START_Y + static_cast<int>(static_cast<float>(FINAL_Y - START_Y) * EASED_Y);
|
||||
float cur_x = (SCREEN_W - TOTAL_W) / 2.0F;
|
||||
for (const auto& seg : info_segments) {
|
||||
if (seg.anim <= 0.01F || seg.text.empty()) {
|
||||
continue;
|
||||
}
|
||||
const int XI = static_cast<int>(cur_x);
|
||||
const int SEG_W = seg.mono_digits
|
||||
? font->widthMonoDigits(seg.text.c_str(), DIGIT_CELL)
|
||||
: font->width(seg.text.c_str());
|
||||
drawInfoSegment(pixel_data, seg, XI, INFO_Y, DIGIT_CELL);
|
||||
cur_x += static_cast<float>(SEG_W) * Easing::outQuad(seg.anim);
|
||||
}
|
||||
}
|
||||
|
||||
// Indicador de pausa persistent (cantó superior dret)
|
||||
if (Director::get() && Director::get()->isPaused()) {
|
||||
static void renderPauseIndicator(Uint32* pixel_data) {
|
||||
if ((Director::get() == nullptr) || !Director::get()->isPaused()) {
|
||||
return;
|
||||
}
|
||||
const char* pause_text = Locale::get("notifications.pause");
|
||||
int w = font_->width(pause_text);
|
||||
int x = SCREEN_W - w - 4;
|
||||
int y = 4;
|
||||
// Contorn blanc 4-direccional
|
||||
font_->draw(pixel_data, x, y - 1, pause_text, 0xFFFFFFFF);
|
||||
font_->draw(pixel_data, x, y + 1, pause_text, 0xFFFFFFFF);
|
||||
font_->draw(pixel_data, x - 1, y, pause_text, 0xFFFFFFFF);
|
||||
font_->draw(pixel_data, x + 1, y, pause_text, 0xFFFFFFFF);
|
||||
// Text en roig
|
||||
font_->draw(pixel_data, x, y, pause_text, 0xFF0000FF);
|
||||
const int W = font->width(pause_text);
|
||||
const int X = SCREEN_W - W - 4;
|
||||
const int Y = 4;
|
||||
font->draw(pixel_data, X, Y - 1, pause_text, 0xFFFFFFFF);
|
||||
font->draw(pixel_data, X, Y + 1, pause_text, 0xFFFFFFFF);
|
||||
font->draw(pixel_data, X - 1, Y, pause_text, 0xFFFFFFFF);
|
||||
font->draw(pixel_data, X + 1, Y, pause_text, 0xFFFFFFFF);
|
||||
font->draw(pixel_data, X, Y, pause_text, 0xFF0000FF);
|
||||
}
|
||||
|
||||
// Crèdits seqüencials — dispara notificacions TOP_CENTER_DROP una darrere l'altra.
|
||||
if (credits_phase_ != CreditsPhase::IDLE) {
|
||||
credits_timer_ += dt;
|
||||
switch (credits_phase_) {
|
||||
case CreditsPhase::DELAY:
|
||||
if (credits_timer_ >= CREDITS_DELAY) {
|
||||
static void emitCreditsLines(const char* role_key, const char* name_key) {
|
||||
showNotification(
|
||||
{std::string(Locale::get("credits.port_role")),
|
||||
std::string(Locale::get("credits.port_name"))},
|
||||
{std::string(Locale::get(role_key)), std::string(Locale::get(name_key))},
|
||||
CREDITS_HOLD,
|
||||
NotifPosition::TOP_CENTER_DROP,
|
||||
NotifStyle::OUTLINE,
|
||||
CREDITS_BG,
|
||||
CREDITS_FG);
|
||||
credits_phase_ = CreditsPhase::PLAYING_1;
|
||||
credits_timer_ = 0.0F;
|
||||
}
|
||||
|
||||
static void advanceCredits(float dt) {
|
||||
if (credits_phase == CreditsPhase::IDLE) {
|
||||
return;
|
||||
}
|
||||
credits_timer += dt;
|
||||
switch (credits_phase) {
|
||||
case CreditsPhase::DELAY:
|
||||
if (credits_timer >= CREDITS_DELAY) {
|
||||
emitCreditsLines("credits.port_role", "credits.port_name");
|
||||
credits_phase = CreditsPhase::PLAYING_1;
|
||||
credits_timer = 0.0F;
|
||||
}
|
||||
break;
|
||||
case CreditsPhase::PLAYING_1:
|
||||
if (notifications_.empty()) {
|
||||
credits_phase_ = CreditsPhase::GAP;
|
||||
credits_timer_ = 0.0F;
|
||||
if (notifications.empty()) {
|
||||
credits_phase = CreditsPhase::GAP;
|
||||
credits_timer = 0.0F;
|
||||
}
|
||||
break;
|
||||
case CreditsPhase::GAP:
|
||||
if (credits_timer_ >= CREDITS_GAP) {
|
||||
showNotification(
|
||||
{std::string(Locale::get("credits.modern_role")),
|
||||
std::string(Locale::get("credits.modern_name"))},
|
||||
CREDITS_HOLD,
|
||||
NotifPosition::TOP_CENTER_DROP,
|
||||
NotifStyle::OUTLINE,
|
||||
CREDITS_BG,
|
||||
CREDITS_FG);
|
||||
credits_phase_ = CreditsPhase::PLAYING_2;
|
||||
credits_timer_ = 0.0F;
|
||||
if (credits_timer >= CREDITS_GAP) {
|
||||
emitCreditsLines("credits.modern_role", "credits.modern_name");
|
||||
credits_phase = CreditsPhase::PLAYING_2;
|
||||
credits_timer = 0.0F;
|
||||
}
|
||||
break;
|
||||
case CreditsPhase::PLAYING_2:
|
||||
if (notifications_.empty()) {
|
||||
credits_phase_ = CreditsPhase::IDLE;
|
||||
credits_timer_ = 0.0F;
|
||||
if (notifications.empty()) {
|
||||
credits_phase = CreditsPhase::IDLE;
|
||||
credits_timer = 0.0F;
|
||||
}
|
||||
break;
|
||||
case CreditsPhase::IDLE:
|
||||
@@ -356,13 +322,34 @@ namespace Overlay {
|
||||
}
|
||||
}
|
||||
|
||||
// Neteja notificacions finalitzades
|
||||
notifications_.erase(
|
||||
std::remove_if(notifications_.begin(), notifications_.end(), [](const Notification& n) { return n.status == Status::FINISHED; }),
|
||||
notifications_.end());
|
||||
void render(Uint32* pixel_data) {
|
||||
if (!font || (pixel_data == nullptr)) {
|
||||
return;
|
||||
}
|
||||
const Uint32 NOW = SDL_GetTicks();
|
||||
const float DT = static_cast<float>(NOW - last_ticks) / 1000.0F;
|
||||
last_ticks = NOW;
|
||||
|
||||
// Menú flotant per damunt de tot
|
||||
if (Menu::isOpen()) {
|
||||
for (auto& notif : notifications) {
|
||||
updateNotifFsm(notif, DT);
|
||||
if (notif.status != Status::FINISHED) {
|
||||
renderOneNotification(pixel_data, notif);
|
||||
}
|
||||
}
|
||||
|
||||
updateRenderInfoFsm(DT);
|
||||
renderRenderInfo(pixel_data);
|
||||
|
||||
std::erase_if(notifications, [](const Notification& n) { return n.status == Status::FINISHED; });
|
||||
if (esc_waiting && notifications.empty()) {
|
||||
esc_waiting = false;
|
||||
}
|
||||
|
||||
renderPauseIndicator(pixel_data);
|
||||
advanceCredits(DT);
|
||||
|
||||
std::erase_if(notifications, [](const Notification& n) { return n.status == Status::FINISHED; });
|
||||
if (Menu::isVisible()) {
|
||||
Menu::render(pixel_data);
|
||||
}
|
||||
}
|
||||
@@ -378,7 +365,7 @@ namespace Overlay {
|
||||
Uint32 accent_color,
|
||||
Uint32 text_color) {
|
||||
// Reemplaça la notificació anterior
|
||||
notifications_.clear();
|
||||
notifications.clear();
|
||||
|
||||
Notification notif;
|
||||
notif.lines = lines;
|
||||
@@ -391,15 +378,15 @@ namespace Overlay {
|
||||
// Calcula l'amplada màxima de les línies
|
||||
int max_w = 0;
|
||||
for (const auto& line : lines) {
|
||||
int w = font_->width(line.c_str());
|
||||
if (w > max_w) max_w = w;
|
||||
int w = font->width(line.c_str());
|
||||
max_w = std::max(w, max_w);
|
||||
}
|
||||
notif.box_w = max_w + NOTIF_PADDING_H * 2;
|
||||
int line_h = font_->charHeight();
|
||||
int line_h = font->charHeight();
|
||||
int line_count = static_cast<int>(lines.size());
|
||||
notif.box_h = line_count * line_h + (line_count - 1) * 1 + NOTIF_PADDING_V * 2;
|
||||
|
||||
notifications_.push_back(notif);
|
||||
notifications.push_back(notif);
|
||||
}
|
||||
|
||||
void toggleRenderInfo() { cycleRenderInfo(+1); }
|
||||
@@ -414,45 +401,47 @@ namespace Overlay {
|
||||
void setRenderInfoSegments(const char* s0, const char* s1, const char* s2, const char* s3, unsigned int mono_mask) {
|
||||
const char* segs[INFO_SEGMENT_COUNT] = {s0, s1, s2, s3};
|
||||
for (int i = 0; i < INFO_SEGMENT_COUNT; i++) {
|
||||
info_segments_[i].mono_digits = (mono_mask >> i) & 1u;
|
||||
info_segments[i].mono_digits = (((mono_mask >> i) & 1U) != 0U);
|
||||
if (segs[i] != nullptr && *segs[i] != '\0') {
|
||||
info_segments_[i].text = segs[i];
|
||||
info_segments_[i].visible = true;
|
||||
info_segments[i].text = segs[i];
|
||||
info_segments[i].visible = true;
|
||||
} else {
|
||||
info_segments_[i].visible = false;
|
||||
info_segments[i].visible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void startCredits() {
|
||||
if (credits_phase_ != CreditsPhase::IDLE) return;
|
||||
credits_phase_ = CreditsPhase::DELAY;
|
||||
credits_timer_ = 0.0F;
|
||||
if (credits_phase != CreditsPhase::IDLE) {
|
||||
return;
|
||||
}
|
||||
credits_phase = CreditsPhase::DELAY;
|
||||
credits_timer = 0.0F;
|
||||
}
|
||||
|
||||
void cancelCredits() {
|
||||
credits_phase_ = CreditsPhase::IDLE;
|
||||
credits_timer_ = 0.0F;
|
||||
notifications_.clear();
|
||||
credits_phase = CreditsPhase::IDLE;
|
||||
credits_timer = 0.0F;
|
||||
notifications.clear();
|
||||
}
|
||||
|
||||
auto creditsActive() -> bool {
|
||||
return credits_phase_ != CreditsPhase::IDLE;
|
||||
return credits_phase != CreditsPhase::IDLE;
|
||||
}
|
||||
|
||||
auto isEscConsumed() -> bool {
|
||||
return esc_waiting_;
|
||||
return esc_waiting;
|
||||
}
|
||||
|
||||
auto handleEscape() -> bool {
|
||||
if (!esc_waiting_) {
|
||||
if (!esc_waiting) {
|
||||
// Primera pulsació: mostra avís i consumeix
|
||||
esc_waiting_ = true;
|
||||
esc_waiting = true;
|
||||
showNotification(Locale::get("notifications.exit_double_esc"), 2.0F);
|
||||
return true; // Consumit
|
||||
}
|
||||
// Segona pulsació: deixa passar
|
||||
esc_waiting_ = false;
|
||||
esc_waiting = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -13,13 +14,13 @@ namespace Overlay {
|
||||
void render(Uint32* pixel_data);
|
||||
|
||||
// Posició + animació d'una notificació
|
||||
enum class NotifPosition {
|
||||
enum class NotifPosition : std::uint8_t {
|
||||
TOP_LEFT_SLIDE, // Cantó superior esquerra, slide horizontal des de fora
|
||||
TOP_CENTER_DROP, // Centrat horitzontal, baixa des de sobre
|
||||
};
|
||||
|
||||
// Estil de la notificació: caixa de fons, ombra o contorn del text
|
||||
enum class NotifStyle {
|
||||
enum class NotifStyle : std::uint8_t {
|
||||
BOX, // Rectangle de fons amb accent_color
|
||||
SHADOW, // Sense fons, text amb ombra (offset +1,+1) en accent_color
|
||||
OUTLINE, // Sense fons, text amb contorn 4-direccional en accent_color
|
||||
|
||||
@@ -1,28 +1,73 @@
|
||||
#include "core/rendering/screen.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <iostream>
|
||||
|
||||
#include "core/locale/locale.hpp"
|
||||
#include "core/rendering/overlay.hpp"
|
||||
#ifndef NO_SHADERS
|
||||
#include "core/rendering/sdl3gpu/sdl3gpu_shader.hpp"
|
||||
#endif
|
||||
#include "game/defines.hpp"
|
||||
#include "game/options.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() {
|
||||
instance_ = new Screen();
|
||||
instance = std::unique_ptr<Screen>(new Screen());
|
||||
}
|
||||
|
||||
void Screen::destroy() {
|
||||
delete instance_;
|
||||
instance_ = nullptr;
|
||||
instance.reset();
|
||||
}
|
||||
|
||||
auto Screen::get() -> Screen* {
|
||||
return instance_;
|
||||
return instance.get();
|
||||
}
|
||||
|
||||
Screen::Screen() {
|
||||
@@ -32,44 +77,86 @@ Screen::Screen() {
|
||||
|
||||
calculateMaxZoom();
|
||||
|
||||
if (zoom_ < 1) zoom_ = 1;
|
||||
if (zoom_ > max_zoom_) zoom_ = max_zoom_;
|
||||
zoom_ = std::max(zoom_, 1);
|
||||
zoom_ = std::min(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.
|
||||
Options::video.internal_resolution = std::max(Options::video.internal_resolution, 1);
|
||||
Options::video.internal_resolution = std::min(Options::video.internal_resolution, max_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_;
|
||||
|
||||
window_ = SDL_CreateWindow(Locale::get("window.title"), w, h, fullscreen_ ? SDL_WINDOW_FULLSCREEN : 0);
|
||||
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);
|
||||
SDL_SetTextureScaleMode(texture_, SDL_SCALEMODE_NEAREST);
|
||||
applyFallbackPresentation();
|
||||
|
||||
// Inicialitza backend GPU si l'acceleració està activada
|
||||
initShaders();
|
||||
|
||||
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() {
|
||||
#ifdef __EMSCRIPTEN__
|
||||
g_screen_instance = nullptr;
|
||||
#endif
|
||||
|
||||
// Guarda opcions abans de destruir
|
||||
Options::window.zoom = zoom_;
|
||||
Options::window.fullscreen = fullscreen_;
|
||||
|
||||
// Destrueix el backend GPU
|
||||
// Destrueix el backend GPU (només existeix si s'ha compilat amb shaders)
|
||||
if (shader_backend_) {
|
||||
#ifndef NO_SHADERS
|
||||
auto* gpu = dynamic_cast<Rendering::SDL3GPUShader*>(shader_backend_.get());
|
||||
if (gpu) gpu->destroy();
|
||||
if (gpu != nullptr) {
|
||||
gpu->destroy();
|
||||
}
|
||||
#endif
|
||||
shader_backend_.reset();
|
||||
}
|
||||
|
||||
if (texture_) SDL_DestroyTexture(texture_);
|
||||
if (renderer_) SDL_DestroyRenderer(renderer_);
|
||||
if (window_) SDL_DestroyWindow(window_);
|
||||
if (internal_texture_sdl_ != nullptr) {
|
||||
SDL_DestroyTexture(internal_texture_sdl_);
|
||||
}
|
||||
if (texture_ != nullptr) {
|
||||
SDL_DestroyTexture(texture_);
|
||||
}
|
||||
if (renderer_ != nullptr) {
|
||||
SDL_DestroyRenderer(renderer_);
|
||||
}
|
||||
if (window_ != nullptr) {
|
||||
SDL_DestroyWindow(window_);
|
||||
}
|
||||
}
|
||||
|
||||
void Screen::initShaders() {
|
||||
if (!Options::video.gpu_acceleration) return;
|
||||
#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;
|
||||
}
|
||||
|
||||
shader_backend_ = std::make_unique<Rendering::SDL3GPUShader>();
|
||||
|
||||
@@ -88,16 +175,11 @@ void Screen::initShaders() {
|
||||
std::cout << "GPU driver: " << gpu_driver_ << '\n';
|
||||
|
||||
// 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_->setStretchFilter(Options::video.stretch_filter_linear);
|
||||
shader_backend_->setStretch4_3(Options::video.aspect_ratio_4_3);
|
||||
shader_backend_->setLinearUpscale(Options::video.linear_upscale);
|
||||
shader_backend_->setDownscaleAlgo(Options::video.downscale_algo);
|
||||
|
||||
if (Options::video.supersampling) {
|
||||
shader_backend_->setOversample(3);
|
||||
}
|
||||
shader_backend_->setTextureFilter(Options::video.texture_filter);
|
||||
shader_backend_->setStretch43(Options::video.aspect_ratio_4_3);
|
||||
shader_backend_->setInternalResolution(Options::video.internal_resolution);
|
||||
|
||||
// Resol el shader actiu des del config
|
||||
if (Options::video.current_shader == "crtpi") {
|
||||
@@ -122,6 +204,7 @@ void Screen::initShaders() {
|
||||
|
||||
applyCurrentPostFXPreset();
|
||||
applyCurrentCrtPiPreset();
|
||||
#endif
|
||||
}
|
||||
|
||||
void Screen::present(Uint32* pixel_data) {
|
||||
@@ -135,14 +218,62 @@ void Screen::present(Uint32* pixel_data) {
|
||||
shader_backend_->uploadPixels(pixel_data, GAME_WIDTH, GAME_HEIGHT);
|
||||
shader_backend_->render();
|
||||
} 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{};
|
||||
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_->render();
|
||||
if (PREV_SHADER != Rendering::ShaderType::POSTFX) {
|
||||
shader_backend_->setActiveShader(PREV_SHADER);
|
||||
}
|
||||
} 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));
|
||||
|
||||
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_RenderTexture(renderer_, texture_, nullptr, nullptr);
|
||||
SDL_RenderPresent(renderer_);
|
||||
@@ -158,19 +289,25 @@ void Screen::toggleFullscreen() {
|
||||
}
|
||||
|
||||
void Screen::incZoom() {
|
||||
if (fullscreen_ || zoom_ >= max_zoom_) return;
|
||||
if (fullscreen_ || zoom_ >= max_zoom_) {
|
||||
return;
|
||||
}
|
||||
zoom_++;
|
||||
adjustWindowSize();
|
||||
}
|
||||
|
||||
void Screen::decZoom() {
|
||||
if (fullscreen_ || zoom_ <= 1) return;
|
||||
if (fullscreen_ || zoom_ <= 1) {
|
||||
return;
|
||||
}
|
||||
zoom_--;
|
||||
adjustWindowSize();
|
||||
}
|
||||
|
||||
void Screen::setZoom(int zoom) {
|
||||
if (zoom < 1 || zoom > max_zoom_ || fullscreen_) return;
|
||||
if (zoom < 1 || zoom > max_zoom_ || fullscreen_) {
|
||||
return;
|
||||
}
|
||||
zoom_ = zoom;
|
||||
adjustWindowSize();
|
||||
}
|
||||
@@ -182,26 +319,28 @@ void Screen::toggleShaders() {
|
||||
}
|
||||
}
|
||||
|
||||
void Screen::toggleSupersampling() {
|
||||
if (!shader_backend_ || !shader_backend_->isHardwareAccelerated()) return;
|
||||
Options::video.supersampling = !Options::video.supersampling;
|
||||
shader_backend_->setOversample(Options::video.supersampling ? 3 : 1);
|
||||
}
|
||||
|
||||
void Screen::toggleAspectRatio() {
|
||||
Options::video.aspect_ratio_4_3 = !Options::video.aspect_ratio_4_3;
|
||||
if (shader_backend_) {
|
||||
shader_backend_->setStretch4_3(Options::video.aspect_ratio_4_3);
|
||||
shader_backend_->setStretch43(Options::video.aspect_ratio_4_3);
|
||||
} else {
|
||||
applyFallbackPresentation();
|
||||
}
|
||||
if (!fullscreen_) {
|
||||
adjustWindowSize();
|
||||
}
|
||||
}
|
||||
|
||||
void Screen::toggleIntegerScale() {
|
||||
Options::video.integer_scale = !Options::video.integer_scale;
|
||||
void Screen::cycleScalingMode(int dir) {
|
||||
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_) {
|
||||
shader_backend_->setScaleMode(Options::video.integer_scale);
|
||||
shader_backend_->setScalingMode(Options::video.scaling_mode);
|
||||
} else {
|
||||
applyFallbackPresentation();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,15 +351,45 @@ void Screen::toggleVSync() {
|
||||
}
|
||||
}
|
||||
|
||||
void Screen::toggleStretchFilter() {
|
||||
Options::video.stretch_filter_linear = !Options::video.stretch_filter_linear;
|
||||
void Screen::cycleTextureFilter(int dir) {
|
||||
// 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_) {
|
||||
shader_backend_->setStretchFilter(Options::video.stretch_filter_linear);
|
||||
shader_backend_->setTextureFilter(Options::video.texture_filter);
|
||||
} else {
|
||||
applyFallbackPresentation();
|
||||
}
|
||||
}
|
||||
|
||||
void Screen::nextShaderType() {
|
||||
if (!shader_backend_ || !shader_backend_->isHardwareAccelerated()) return;
|
||||
void Screen::changeInternalResolution(int dir) {
|
||||
int next = Options::video.internal_resolution + (dir >= 0 ? 1 : -1);
|
||||
next = std::max(next, 1);
|
||||
next = std::min(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) {
|
||||
shader_backend_->setActiveShader(Rendering::ShaderType::CRTPI);
|
||||
@@ -231,56 +400,81 @@ void Screen::nextShaderType() {
|
||||
Options::video.current_shader = "postfx";
|
||||
applyCurrentPostFXPreset();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void Screen::nextPreset() {
|
||||
if (!shader_backend_ || !shader_backend_->isHardwareAccelerated()) return;
|
||||
auto Screen::nextPreset() -> bool {
|
||||
if (!shader_backend_ || !shader_backend_->isHardwareAccelerated()) {
|
||||
return false;
|
||||
}
|
||||
if (!Options::video.shader_enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
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::video.current_postfx_preset = Options::postfx_presets[Options::current_postfx_preset].name;
|
||||
applyCurrentPostFXPreset();
|
||||
} 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::video.current_crtpi_preset = Options::crtpi_presets[Options::current_crtpi_preset].name;
|
||||
applyCurrentCrtPiPreset();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void Screen::prevShaderType() {
|
||||
auto Screen::prevShaderType() -> bool {
|
||||
// Només dues opcions — prev == next
|
||||
nextShaderType();
|
||||
return nextShaderType();
|
||||
}
|
||||
|
||||
void Screen::prevPreset() {
|
||||
if (!shader_backend_ || !shader_backend_->isHardwareAccelerated()) return;
|
||||
auto Screen::prevPreset() -> bool {
|
||||
if (!shader_backend_ || !shader_backend_->isHardwareAccelerated()) {
|
||||
return false;
|
||||
}
|
||||
if (!Options::video.shader_enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
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());
|
||||
Options::current_postfx_preset = (Options::current_postfx_preset - 1 + n) % n;
|
||||
Options::video.current_postfx_preset = Options::postfx_presets[Options::current_postfx_preset].name;
|
||||
applyCurrentPostFXPreset();
|
||||
} else {
|
||||
if (Options::crtpi_presets.empty()) return;
|
||||
if (Options::crtpi_presets.empty()) {
|
||||
return false;
|
||||
}
|
||||
int n = static_cast<int>(Options::crtpi_presets.size());
|
||||
Options::current_crtpi_preset = (Options::current_crtpi_preset - 1 + n) % n;
|
||||
Options::video.current_crtpi_preset = Options::crtpi_presets[Options::current_crtpi_preset].name;
|
||||
applyCurrentCrtPiPreset();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
auto Screen::getCurrentPresetName() const -> const char* {
|
||||
if (!shader_backend_ || !shader_backend_->isHardwareAccelerated()) return "---";
|
||||
if (!shader_backend_ || !shader_backend_->isHardwareAccelerated()) {
|
||||
return "---";
|
||||
}
|
||||
if (shader_backend_->getActiveShader() == Rendering::ShaderType::POSTFX) {
|
||||
if (Options::current_postfx_preset < static_cast<int>(Options::postfx_presets.size()))
|
||||
if (Options::current_postfx_preset < static_cast<int>(Options::postfx_presets.size())) {
|
||||
return Options::postfx_presets[Options::current_postfx_preset].name.c_str();
|
||||
}
|
||||
} else {
|
||||
if (Options::current_crtpi_preset < static_cast<int>(Options::crtpi_presets.size()))
|
||||
if (Options::current_crtpi_preset < static_cast<int>(Options::crtpi_presets.size())) {
|
||||
return Options::crtpi_presets[Options::current_crtpi_preset].name.c_str();
|
||||
}
|
||||
}
|
||||
return "---";
|
||||
}
|
||||
|
||||
@@ -291,22 +485,30 @@ void Screen::setActiveShader(Rendering::ShaderType type) {
|
||||
}
|
||||
|
||||
void Screen::applyCurrentPostFXPreset() {
|
||||
if (!shader_backend_ || Options::postfx_presets.empty()) return;
|
||||
if (!shader_backend_ || Options::postfx_presets.empty()) {
|
||||
return;
|
||||
}
|
||||
const auto& preset = Options::postfx_presets[Options::current_postfx_preset];
|
||||
Rendering::PostFXParams p;
|
||||
p.vignette = preset.vignette;
|
||||
p.scanlines = preset.scanlines;
|
||||
p.chroma = preset.chroma;
|
||||
p.chroma_min = preset.chroma_min;
|
||||
p.chroma_max = preset.chroma_max;
|
||||
p.mask = preset.mask;
|
||||
p.gamma = preset.gamma;
|
||||
p.curvature = preset.curvature;
|
||||
p.bleeding = preset.bleeding;
|
||||
p.flicker = preset.flicker;
|
||||
p.scan_dark_ratio = preset.scan_dark_ratio;
|
||||
p.scan_dark_floor = preset.scan_dark_floor;
|
||||
p.scan_edge_soft = preset.scan_edge_soft;
|
||||
shader_backend_->setPostFXParams(p);
|
||||
}
|
||||
|
||||
void Screen::applyCurrentCrtPiPreset() {
|
||||
if (!shader_backend_ || Options::crtpi_presets.empty()) return;
|
||||
if (!shader_backend_ || Options::crtpi_presets.empty()) {
|
||||
return;
|
||||
}
|
||||
const auto& preset = Options::crtpi_presets[Options::current_crtpi_preset];
|
||||
Rendering::CrtPiParams p;
|
||||
p.scanline_weight = preset.scanline_weight;
|
||||
@@ -331,12 +533,14 @@ auto Screen::isHardwareAccelerated() const -> bool {
|
||||
}
|
||||
|
||||
auto Screen::getActiveShaderName() const -> const char* {
|
||||
if (!shader_backend_ || !shader_backend_->isHardwareAccelerated()) return "SENSE GPU";
|
||||
if (!shader_backend_ || !shader_backend_->isHardwareAccelerated()) {
|
||||
return "SENSE GPU";
|
||||
}
|
||||
return shader_backend_->getActiveShader() == Rendering::ShaderType::POSTFX ? "POSTFX" : "CRT-PI";
|
||||
}
|
||||
|
||||
void Screen::updateRenderInfo() {
|
||||
static const Uint32 start_ticks = SDL_GetTicks();
|
||||
static const Uint32 START_TICKS = SDL_GetTicks();
|
||||
std::string driver = gpu_driver_.empty() ? "sdl" : toLower(gpu_driver_);
|
||||
|
||||
// Segment 0: FPS + driver (sempre visible)
|
||||
@@ -348,27 +552,101 @@ void Screen::updateRenderInfo() {
|
||||
shader_seg = " - " + toLower(getActiveShaderName()) + " " + toLower(getCurrentPresetName());
|
||||
}
|
||||
|
||||
// Segment 2: supersampling indicator
|
||||
const char* ss_seg = (Options::video.shader_enabled && Options::video.supersampling) ? " (ss)" : nullptr;
|
||||
|
||||
// Segment 3: hora (només si show_time)
|
||||
// Segment 2: hora (només si show_time)
|
||||
char time_buf[32] = {0};
|
||||
if (Options::render_info.show_time) {
|
||||
Uint32 elapsed = SDL_GetTicks() - start_ticks;
|
||||
Uint32 elapsed = SDL_GetTicks() - START_TICKS;
|
||||
int minutes = elapsed / 60000;
|
||||
int seconds = (elapsed / 1000) % 60;
|
||||
int centis = (elapsed / 10) % 100;
|
||||
snprintf(time_buf, sizeof(time_buf), " - %d:%02d.%02d", minutes, seconds, centis);
|
||||
}
|
||||
|
||||
// Dígits en mono a FPS (segment 0) i TEMPS (segment 3): els dígits canvien
|
||||
// Dígits en mono a FPS (segment 0) i TEMPS (segment 2): els dígits canvien
|
||||
// contínuament mentre els símbols del voltant ("fps", ":", ".", " - ") no
|
||||
Overlay::setRenderInfoSegments(
|
||||
fps_driver.c_str(),
|
||||
shader_seg.empty() ? nullptr : shader_seg.c_str(),
|
||||
ss_seg,
|
||||
time_buf[0] ? time_buf : nullptr,
|
||||
0b1001);
|
||||
(time_buf[0] != 0) ? time_buf : nullptr,
|
||||
nullptr,
|
||||
0b0101);
|
||||
}
|
||||
|
||||
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_ != nullptr) {
|
||||
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() {
|
||||
@@ -382,10 +660,32 @@ void Screen::adjustWindowSize() {
|
||||
void Screen::calculateMaxZoom() {
|
||||
SDL_DisplayID display = SDL_GetPrimaryDisplay();
|
||||
const SDL_DisplayMode* mode = SDL_GetCurrentDisplayMode(display);
|
||||
if (mode) {
|
||||
if (mode != nullptr) {
|
||||
int max_w = mode->w / GAME_WIDTH;
|
||||
int max_h = mode->h / GAME_HEIGHT;
|
||||
max_zoom_ = (max_w < max_h) ? max_w : max_h;
|
||||
if (max_zoom_ < 1) max_zoom_ = 1;
|
||||
max_zoom_ = std::max(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
|
||||
|
||||
@@ -13,6 +13,8 @@ class Screen {
|
||||
static void destroy();
|
||||
static auto get() -> Screen*;
|
||||
|
||||
~Screen(); // públic per a std::unique_ptr
|
||||
|
||||
// Presentació — rep el buffer ARGB de 320x200 de JD8
|
||||
void present(Uint32* pixel_data);
|
||||
|
||||
@@ -23,16 +25,20 @@ class Screen {
|
||||
void setZoom(int zoom);
|
||||
|
||||
// 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 toggleSupersampling();
|
||||
void toggleAspectRatio();
|
||||
void toggleIntegerScale();
|
||||
void cycleScalingMode(int dir); // Cicla DISABLED/STRETCH/LETTERBOX/OVERSCAN/INTEGER
|
||||
void toggleVSync();
|
||||
void toggleStretchFilter();
|
||||
void nextShaderType(); // Cicla PostFX ↔ CrtPi (F7)
|
||||
void prevShaderType(); // Cicla al revés
|
||||
void nextPreset(); // Cicla presets del shader actiu (F8)
|
||||
void prevPreset(); // Cicla presets al revés
|
||||
void cycleTextureFilter(int dir); // Cicla NEAREST/LINEAR
|
||||
void changeInternalResolution(int dir); // +/−1, clampat a [1, max_zoom_]
|
||||
auto nextShaderType() -> bool; // false si GPU off / shaders off
|
||||
auto prevShaderType() -> bool; // idem
|
||||
auto nextPreset() -> bool; // false si GPU off / shaders off
|
||||
auto prevPreset() -> bool; // idem
|
||||
[[nodiscard]] auto getCurrentPresetName() const -> const char*;
|
||||
void setActiveShader(Rendering::ShaderType type);
|
||||
void applyCurrentPostFXPreset();
|
||||
@@ -41,24 +47,36 @@ class Screen {
|
||||
// Getters
|
||||
[[nodiscard]] auto isFullscreen() const -> bool { return fullscreen_; }
|
||||
[[nodiscard]] auto getZoom() const -> int { return zoom_; }
|
||||
[[nodiscard]] auto getMaxZoom() const -> int { return max_zoom_; }
|
||||
[[nodiscard]] auto isHardwareAccelerated() const -> bool;
|
||||
[[nodiscard]] auto getActiveShaderName() const -> const char*;
|
||||
[[nodiscard]] auto getWindow() -> SDL_Window* { return window_; }
|
||||
[[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:
|
||||
Screen();
|
||||
~Screen();
|
||||
|
||||
void adjustWindowSize();
|
||||
void calculateMaxZoom();
|
||||
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_Renderer* renderer_{nullptr};
|
||||
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)
|
||||
std::unique_ptr<Rendering::ShaderBackend> shader_backend_;
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef __APPLE__
|
||||
|
||||
// Fragment shader del shader "crtpi" (algoritme CRT-Pi): scanlines amb
|
||||
// pesos gaussians, multisample opcional, gamma i màscara de subpíxels.
|
||||
namespace Rendering::Msl {
|
||||
|
||||
inline constexpr const char* kCrtpiFrag = R"(
|
||||
#include <metal_stdlib>
|
||||
using namespace metal;
|
||||
|
||||
struct PostVOut {
|
||||
float4 pos [[position]];
|
||||
float2 uv;
|
||||
};
|
||||
|
||||
struct CrtPiUniforms {
|
||||
float scanline_weight;
|
||||
float scanline_gap_brightness;
|
||||
float bloom_factor;
|
||||
float input_gamma;
|
||||
float output_gamma;
|
||||
float mask_brightness;
|
||||
float curvature_x;
|
||||
float curvature_y;
|
||||
int mask_type;
|
||||
int enable_scanlines;
|
||||
int enable_multisample;
|
||||
int enable_gamma;
|
||||
int enable_curvature;
|
||||
int enable_sharper;
|
||||
float texture_width;
|
||||
float texture_height;
|
||||
};
|
||||
|
||||
static float2 crtpi_distort(float2 coord, float2 screen_scale, float cx, float cy) {
|
||||
float2 curvature = float2(cx, cy);
|
||||
float2 barrel_scale = 1.0f - (0.23f * curvature);
|
||||
coord *= screen_scale;
|
||||
coord -= 0.5f;
|
||||
float rsq = coord.x * coord.x + coord.y * coord.y;
|
||||
coord += coord * (curvature * rsq);
|
||||
coord *= barrel_scale;
|
||||
if (abs(coord.x) >= 0.5f || abs(coord.y) >= 0.5f) { return float2(-1.0f); }
|
||||
coord += 0.5f;
|
||||
coord /= screen_scale;
|
||||
return coord;
|
||||
}
|
||||
|
||||
static float crtpi_scan_weight(float dist, float sw, float gap) {
|
||||
return max(1.0f - dist * dist * sw, gap);
|
||||
}
|
||||
|
||||
static float crtpi_scan_line(float dy, float filter_w, float sw, float gap, bool ms) {
|
||||
float w = crtpi_scan_weight(dy, sw, gap);
|
||||
if (ms) {
|
||||
w += crtpi_scan_weight(dy - filter_w, sw, gap);
|
||||
w += crtpi_scan_weight(dy + filter_w, sw, gap);
|
||||
w *= 0.3333333f;
|
||||
}
|
||||
return w;
|
||||
}
|
||||
|
||||
fragment float4 crtpi_fs(PostVOut in [[stage_in]],
|
||||
texture2d<float> tex [[texture(0)]],
|
||||
sampler samp [[sampler(0)]],
|
||||
constant CrtPiUniforms& u [[buffer(0)]]) {
|
||||
float2 tex_size = float2(u.texture_width, u.texture_height);
|
||||
// Amplada del filtre de scanline analític. 768 = alçada de referència
|
||||
// CRT a la qual es va tarar l'algoritme original; 3 = divisió per
|
||||
// subpíxel (R/G/B) del multisample. El resultat escala amb la textura
|
||||
// d'entrada, de manera que més alçada → filtre més fi.
|
||||
const float CRT_REFERENCE_HEIGHT = 768.0f;
|
||||
const float SUBPIXEL_DIV = 3.0f;
|
||||
float filter_width = (CRT_REFERENCE_HEIGHT / u.texture_height) / SUBPIXEL_DIV;
|
||||
float2 texcoord = in.uv;
|
||||
|
||||
if (u.enable_curvature != 0) {
|
||||
texcoord = crtpi_distort(texcoord, float2(1.0f, 1.0f), u.curvature_x, u.curvature_y);
|
||||
if (texcoord.x < 0.0f) { return float4(0.0f, 0.0f, 0.0f, 1.0f); }
|
||||
}
|
||||
|
||||
float2 coord_in_pixels = texcoord * tex_size;
|
||||
float2 tc;
|
||||
float scan_weight;
|
||||
|
||||
if (u.enable_sharper != 0) {
|
||||
float2 temp = floor(coord_in_pixels) + 0.5f;
|
||||
tc = temp / tex_size;
|
||||
float2 deltas = coord_in_pixels - temp;
|
||||
scan_weight = crtpi_scan_line(deltas.y, filter_width, u.scanline_weight, u.scanline_gap_brightness, u.enable_multisample != 0);
|
||||
float2 signs = sign(deltas);
|
||||
deltas.x *= 2.0f;
|
||||
deltas = deltas * deltas;
|
||||
deltas.y = deltas.y * deltas.y;
|
||||
deltas.x *= 0.5f;
|
||||
deltas.y *= 8.0f;
|
||||
deltas /= tex_size;
|
||||
deltas *= signs;
|
||||
tc = tc + deltas;
|
||||
} else {
|
||||
float temp_y = floor(coord_in_pixels.y) + 0.5f;
|
||||
float y_coord = temp_y / tex_size.y;
|
||||
float dy = coord_in_pixels.y - temp_y;
|
||||
scan_weight = crtpi_scan_line(dy, filter_width, u.scanline_weight, u.scanline_gap_brightness, u.enable_multisample != 0);
|
||||
float sign_y = sign(dy);
|
||||
dy = dy * dy;
|
||||
dy = dy * dy;
|
||||
dy *= 8.0f;
|
||||
dy /= tex_size.y;
|
||||
dy *= sign_y;
|
||||
tc = float2(texcoord.x, y_coord + dy);
|
||||
}
|
||||
|
||||
float3 colour = tex.sample(samp, tc).rgb;
|
||||
|
||||
if (u.enable_scanlines != 0) {
|
||||
if (u.enable_gamma != 0) { colour = pow(colour, float3(u.input_gamma)); }
|
||||
colour *= scan_weight * u.bloom_factor;
|
||||
if (u.enable_gamma != 0) { colour = pow(colour, float3(1.0f / u.output_gamma)); }
|
||||
}
|
||||
|
||||
if (u.mask_type == 1) {
|
||||
float wm = fract(in.pos.x * 0.5f);
|
||||
float3 mask = (wm < 0.5f) ? float3(u.mask_brightness, 1.0f, u.mask_brightness)
|
||||
: float3(1.0f, u.mask_brightness, 1.0f);
|
||||
colour *= mask;
|
||||
} else if (u.mask_type == 2) {
|
||||
float wm = fract(in.pos.x * 0.3333333f);
|
||||
float3 mask = float3(u.mask_brightness);
|
||||
if (wm < 0.3333333f) mask.x = 1.0f;
|
||||
else if (wm < 0.6666666f) mask.y = 1.0f;
|
||||
else mask.z = 1.0f;
|
||||
colour *= mask;
|
||||
}
|
||||
|
||||
return float4(colour, 1.0f);
|
||||
}
|
||||
)";
|
||||
|
||||
} // namespace Rendering::Msl
|
||||
|
||||
#endif // __APPLE__
|
||||
@@ -0,0 +1,168 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef __APPLE__
|
||||
|
||||
// Fragment shader del shader "postfx": vignette, chroma, scanlines, mask,
|
||||
// gamma, curvature, bleeding i flicker. Els paràmetres venen via uniforms.
|
||||
//
|
||||
// IMPORTANT: mantenir sincronitzat a mà amb data/shaders/postfx.frag. SDL3 GPU
|
||||
// compila aquest string MSL en runtime; no hi ha generador automàtic. Qualsevol
|
||||
// canvi a la struct d'uniforms o a la lògica del GLSL cal replicar-lo ací al
|
||||
// mateix commit. Mida total = 64 bytes (4 × vec4).
|
||||
namespace Rendering::Msl {
|
||||
|
||||
inline constexpr const char* kPostfxFrag = R"(
|
||||
#include <metal_stdlib>
|
||||
using namespace metal;
|
||||
|
||||
struct PostVOut {
|
||||
float4 pos [[position]];
|
||||
float2 uv;
|
||||
};
|
||||
|
||||
struct PostFXUniforms {
|
||||
float vignette_strength;
|
||||
float chroma_min;
|
||||
float scanline_strength;
|
||||
float screen_height;
|
||||
float mask_strength;
|
||||
float gamma_strength;
|
||||
float curvature;
|
||||
float bleeding;
|
||||
float pixel_scale;
|
||||
float time;
|
||||
float flicker;
|
||||
float chroma_max;
|
||||
// vec4 #3 — paràmetres de scanlines (exposats per preset YAML)
|
||||
float scan_dark_ratio;
|
||||
float scan_dark_floor;
|
||||
float scan_edge_soft;
|
||||
float pad3;
|
||||
};
|
||||
|
||||
// Mostreig bilinear horitzontal d'un canal RGB. Evita el "tic-tac" del sampler
|
||||
// NEAREST quan l'offset de chroma és subpíxel.
|
||||
static float sampleBilinearX(float2 uv_target, int channel, texture2d<float> scene, sampler samp) {
|
||||
float2 tex_size = float2(scene.get_width(), scene.get_height());
|
||||
float px = uv_target.x * tex_size.x - 0.5f;
|
||||
float p_floor = floor(px);
|
||||
float f = px - p_floor;
|
||||
float4 c0 = scene.sample(samp, float2((p_floor + 0.5f) / tex_size.x, uv_target.y));
|
||||
float4 c1 = scene.sample(samp, float2((p_floor + 1.5f) / tex_size.x, uv_target.y));
|
||||
return mix(c0[channel], c1[channel], f);
|
||||
}
|
||||
|
||||
static float3 rgb_to_ycc(float3 rgb) {
|
||||
return float3(
|
||||
0.299f*rgb.r + 0.587f*rgb.g + 0.114f*rgb.b,
|
||||
-0.169f*rgb.r - 0.331f*rgb.g + 0.500f*rgb.b + 0.5f,
|
||||
0.500f*rgb.r - 0.419f*rgb.g - 0.081f*rgb.b + 0.5f
|
||||
);
|
||||
}
|
||||
static float3 ycc_to_rgb(float3 ycc) {
|
||||
float y = ycc.x;
|
||||
float cb = ycc.y - 0.5f;
|
||||
float cr = ycc.z - 0.5f;
|
||||
return clamp(float3(
|
||||
y + 1.402f*cr,
|
||||
y - 0.344f*cb - 0.714f*cr,
|
||||
y + 1.772f*cb
|
||||
), 0.0f, 1.0f);
|
||||
}
|
||||
|
||||
fragment float4 postfx_fs(PostVOut in [[stage_in]],
|
||||
texture2d<float> scene [[texture(0)]],
|
||||
sampler samp [[sampler(0)]],
|
||||
constant PostFXUniforms& u [[buffer(0)]]) {
|
||||
float2 uv = in.uv;
|
||||
|
||||
if (u.curvature > 0.0f) {
|
||||
float2 c = uv - 0.5f;
|
||||
float rsq = dot(c, c);
|
||||
float2 dist = float2(0.05f, 0.1f) * u.curvature;
|
||||
float2 barrelScale = 1.0f - 0.23f * dist;
|
||||
c += c * (dist * rsq);
|
||||
c *= barrelScale;
|
||||
if (abs(c.x) >= 0.5f || abs(c.y) >= 0.5f) {
|
||||
return float4(0.0f, 0.0f, 0.0f, 1.0f);
|
||||
}
|
||||
uv = c + 0.5f;
|
||||
}
|
||||
|
||||
float3 base = scene.sample(samp, uv).rgb;
|
||||
|
||||
float3 colour;
|
||||
if (u.bleeding > 0.0f) {
|
||||
float tw = float(scene.get_width());
|
||||
float step = 1.0f / tw;
|
||||
float3 ycc = rgb_to_ycc(base);
|
||||
float3 ycc_l2 = rgb_to_ycc(scene.sample(samp, uv - float2(2.0f*step, 0.0f)).rgb);
|
||||
float3 ycc_l1 = rgb_to_ycc(scene.sample(samp, uv - float2(1.0f*step, 0.0f)).rgb);
|
||||
float3 ycc_r1 = rgb_to_ycc(scene.sample(samp, uv + float2(1.0f*step, 0.0f)).rgb);
|
||||
float3 ycc_r2 = rgb_to_ycc(scene.sample(samp, uv + float2(2.0f*step, 0.0f)).rgb);
|
||||
ycc.yz = (ycc_l2.yz + ycc_l1.yz*2.0f + ycc.yz*2.0f + ycc_r1.yz*2.0f + ycc_r2.yz) / 8.0f;
|
||||
colour = mix(base, ycc_to_rgb(ycc), u.bleeding);
|
||||
} else {
|
||||
colour = base;
|
||||
}
|
||||
|
||||
// Chroma — varia entre chroma_min i chroma_max via sinusoidal; si min == max
|
||||
// queda estàtic. Mostreig bilinear horitzontal per evitar el "tic-tac" del
|
||||
// NEAREST sampler amb offsets subpíxel.
|
||||
if (u.chroma_min > 0.0f || u.chroma_max > 0.0f) {
|
||||
float ca = mix(u.chroma_min, u.chroma_max, 0.5f + 0.5f * sin(u.time * 7.3f)) * 0.005f;
|
||||
colour.r = sampleBilinearX(uv + float2(ca, 0.0f), 0, scene, samp);
|
||||
colour.b = sampleBilinearX(uv - float2(ca, 0.0f), 2, scene, samp);
|
||||
}
|
||||
|
||||
if (u.gamma_strength > 0.0f) {
|
||||
float3 lin = pow(colour, float3(2.4f));
|
||||
colour = mix(colour, lin, u.gamma_strength);
|
||||
}
|
||||
|
||||
// Scanlines — 3 subpíxels per fila lògica (2 brillants + 1 fosca). Transició
|
||||
// suavitzada amb smoothstep d'ample ≈ 1 píxel físic (estil crtpi: filtratge
|
||||
// analític continu). scan_edge_soft = 0 recupera el step dur de l'original.
|
||||
if (u.scanline_strength > 0.0f) {
|
||||
float ps = max(u.pixel_scale, 1.0f);
|
||||
float sub = fract(uv.y * u.screen_height);
|
||||
float dark_center = 1.0f - u.scan_dark_ratio * 0.5f;
|
||||
float d = abs(sub - dark_center);
|
||||
d = min(d, 1.0f - d);
|
||||
float half_width = u.scan_dark_ratio * 0.5f;
|
||||
float softness = u.scan_edge_soft * 0.5f / ps;
|
||||
float band = 1.0f - smoothstep(half_width - softness, half_width + softness, d);
|
||||
float scan = mix(1.0f, u.scan_dark_floor, band);
|
||||
colour *= mix(1.0f, scan, u.scanline_strength);
|
||||
}
|
||||
|
||||
if (u.gamma_strength > 0.0f) {
|
||||
float3 enc = pow(colour, float3(1.0f/2.2f));
|
||||
colour = mix(colour, enc, u.gamma_strength);
|
||||
}
|
||||
|
||||
float2 d = uv - 0.5f;
|
||||
float vignette = 1.0f - dot(d, d) * u.vignette_strength;
|
||||
colour *= clamp(vignette, 0.0f, 1.0f);
|
||||
|
||||
if (u.mask_strength > 0.0f) {
|
||||
float whichMask = fract(in.pos.x * 0.3333333f);
|
||||
float3 mask = float3(0.80f);
|
||||
if (whichMask < 0.3333333f) mask.x = 1.0f;
|
||||
else if (whichMask < 0.6666667f) mask.y = 1.0f;
|
||||
else mask.z = 1.0f;
|
||||
colour = mix(colour, colour * mask, u.mask_strength);
|
||||
}
|
||||
|
||||
if (u.flicker > 0.0f) {
|
||||
float flicker_wave = sin(u.time * 100.0f) * 0.5f + 0.5f;
|
||||
colour *= 1.0f - u.flicker * 0.04f * flicker_wave;
|
||||
}
|
||||
|
||||
return float4(colour, 1.0f);
|
||||
}
|
||||
)";
|
||||
|
||||
} // namespace Rendering::Msl
|
||||
|
||||
#endif // __APPLE__
|
||||
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef __APPLE__
|
||||
|
||||
// Vertex shader compartit per tots els pipelines de post-procés:
|
||||
// fullscreen-triangle que cobreix tota l'àrea del swapchain amb UVs a [0,1].
|
||||
namespace Rendering::Msl {
|
||||
|
||||
inline constexpr const char* kPostfxVert = R"(
|
||||
#include <metal_stdlib>
|
||||
using namespace metal;
|
||||
|
||||
struct PostVOut {
|
||||
float4 pos [[position]];
|
||||
float2 uv;
|
||||
};
|
||||
|
||||
vertex PostVOut postfx_vs(uint vid [[vertex_id]]) {
|
||||
const float2 positions[3] = { {-1.0, -1.0}, {3.0, -1.0}, {-1.0, 3.0} };
|
||||
const float2 uvs[3] = { { 0.0, 1.0}, {2.0, 1.0}, { 0.0,-1.0} };
|
||||
PostVOut out;
|
||||
out.pos = float4(positions[vid], 0.0, 1.0);
|
||||
out.uv = uvs[vid];
|
||||
return out;
|
||||
}
|
||||
)";
|
||||
|
||||
} // namespace Rendering::Msl
|
||||
|
||||
#endif // __APPLE__
|
||||
@@ -0,0 +1,23 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef __APPLE__
|
||||
|
||||
// Fragment shader d'upscale (mostreig directe). S'usa per al pas de resolució
|
||||
// interna (scene_texture → internal_texture) quan internal_resolution > 1.
|
||||
namespace Rendering::Msl {
|
||||
|
||||
inline constexpr const char* kUpscaleFrag = R"(
|
||||
#include <metal_stdlib>
|
||||
using namespace metal;
|
||||
struct VertOut { float4 pos [[position]]; float2 uv; };
|
||||
fragment float4 upscale_fs(VertOut in [[stage_in]],
|
||||
texture2d<float> scene [[texture(0)]],
|
||||
sampler smp [[sampler(0)]])
|
||||
{
|
||||
return scene.sample(smp, in.uv);
|
||||
}
|
||||
)";
|
||||
|
||||
} // namespace Rendering::Msl
|
||||
|
||||
#endif // __APPLE__
|
||||
@@ -7,20 +7,28 @@
|
||||
|
||||
// PostFX uniforms pushed to fragment stage each frame.
|
||||
// Must match the MSL struct and GLSL uniform block layout.
|
||||
// 12 floats = 48 bytes — meets Metal/Vulkan 16-byte alignment requirement.
|
||||
// 16 floats = 64 bytes (4 × vec4) — meets Metal/Vulkan 16-byte alignment.
|
||||
struct PostFXUniforms {
|
||||
// vec4 #0
|
||||
float vignette_strength; // 0 = none, ~0.8 = subtle
|
||||
float chroma_strength; // 0 = off, ~0.2 = subtle chromatic aberration
|
||||
float chroma_min; // aberració cromàtica mínima (sempre present)
|
||||
float scanline_strength; // 0 = off, 1 = full
|
||||
float screen_height; // logical height in pixels (used by bleeding effect)
|
||||
// vec4 #1
|
||||
float mask_strength; // 0 = off, 1 = full phosphor dot mask
|
||||
float gamma_strength; // 0 = off, 1 = full gamma 2.4/2.2 correction
|
||||
float curvature; // 0 = flat, 1 = max barrel distortion
|
||||
float bleeding; // 0 = off, 1 = max NTSC chrominance bleeding
|
||||
// vec4 #2
|
||||
float pixel_scale; // physical pixels per logical pixel (vh / tex_height_)
|
||||
float time; // seconds since SDL init (SDL_GetTicks() / 1000.0f)
|
||||
float oversample; // supersampling factor (1.0 = off, 3.0 = 3×SS)
|
||||
float flicker; // 0 = off, 1 = phosphor flicker ~50 Hz — keep struct at 48 bytes (3 × 16)
|
||||
float flicker; // 0 = off, 1 = phosphor flicker ~50 Hz
|
||||
float chroma_max; // si == chroma_min queda estàtic; si != pulsa sinusoidalment
|
||||
// vec4 #3 — paràmetres de forma de les scanlines (exposats per preset)
|
||||
float scan_dark_ratio; // fracció de subfila fosca (1/3 = 0.333 per defecte)
|
||||
float scan_dark_floor; // brillantor de la subfila fosca (0.42 per defecte)
|
||||
float scan_edge_soft; // suavitzat de la transició (0 = step dur, 1 = 1px físic)
|
||||
float pad3;
|
||||
};
|
||||
|
||||
// CrtPi uniforms pushed to fragment stage each frame.
|
||||
@@ -49,15 +57,6 @@ struct CrtPiUniforms {
|
||||
float texture_height; // Alto del canvas en píxeles (inyectado en render)
|
||||
};
|
||||
|
||||
// Downscale uniforms pushed to the Lanczos downscale fragment stage.
|
||||
// 1 int + 3 floats = 16 bytes — meets Metal/Vulkan alignment.
|
||||
struct DownscaleUniforms {
|
||||
int algorithm; // 0 = Lanczos2 (ventana 2), 1 = Lanczos3 (ventana 3)
|
||||
float pad0;
|
||||
float pad1;
|
||||
float pad2;
|
||||
};
|
||||
|
||||
namespace Rendering {
|
||||
|
||||
/**
|
||||
@@ -78,7 +77,7 @@ namespace Rendering {
|
||||
const std::string& fragment_source) -> bool override;
|
||||
|
||||
void render() override;
|
||||
void setTextureSize(float width, float height) override {}
|
||||
void setTextureSize(float /*width*/, float /*height*/) override {}
|
||||
void cleanup() final; // Libera pipeline/texturas pero mantiene el device vivo
|
||||
void destroy(); // Limpieza completa (device + swapchain); llamar solo al cerrar
|
||||
[[nodiscard]] auto isHardwareAccelerated() const -> bool override { return is_initialized_; }
|
||||
@@ -96,20 +95,8 @@ namespace Rendering {
|
||||
// Activa/desactiva VSync en el swapchain
|
||||
void setVSync(bool vsync) override;
|
||||
|
||||
// Activa/desactiva escalado entero (integer scale)
|
||||
void setScaleMode(bool integer_scale) override;
|
||||
|
||||
// Establece factor de supersampling (1 = off, 3 = 3×SS)
|
||||
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
|
||||
void setDownscaleAlgo(int algo) override;
|
||||
|
||||
// Devuelve las dimensiones de la textura de supersampling (0,0 si SS desactivado)
|
||||
[[nodiscard]] auto getSsTextureSize() const -> std::pair<int, int> override;
|
||||
// Selecciona el mode de presentació lògica (DISABLED/STRETCH/LETTERBOX/OVERSCAN/INTEGER)
|
||||
void setScalingMode(Options::ScalingMode mode) override;
|
||||
|
||||
// Selecciona el shader de post-procesado activo (POSTFX o CRTPI)
|
||||
void setActiveShader(ShaderType type) override;
|
||||
@@ -121,9 +108,16 @@ namespace Rendering {
|
||||
[[nodiscard]] auto getActiveShader() const -> ShaderType override { return active_shader_; }
|
||||
|
||||
// Estirament vertical 4:3 (fusionat amb l'upscale pass)
|
||||
void setStretch4_3(bool enabled) override;
|
||||
[[nodiscard]] auto isStretch4_3() const -> bool override { return stretch_4_3_; }
|
||||
void setStretchFilter(bool linear) override { stretch_filter_linear_ = linear; }
|
||||
void setStretch43(bool enabled) override;
|
||||
[[nodiscard]] auto isStretch43() const -> bool override { return stretch_4_3_; }
|
||||
|
||||
// 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:
|
||||
static auto createShaderMSL(SDL_GPUDevice* device,
|
||||
@@ -144,42 +138,67 @@ namespace Rendering {
|
||||
auto createPipeline() -> bool;
|
||||
auto createCrtPiPipeline() -> bool; // Pipeline dedicado para el shader CrtPi
|
||||
auto reinitTexturesAndBuffer() -> bool; // Recrea scene_texture_ y upload_buffer_
|
||||
auto recreateScaledTexture(int factor) -> bool; // Recrea scaled_texture_ para factor dado
|
||||
static auto calcSsFactor(float zoom) -> int; // Primer múltiplo de 3 >= zoom (mín 3)
|
||||
auto recreateInternalTexture() -> bool; // Recrea internal_texture_ (res interna × N)
|
||||
// Devuelve el mejor present mode disponible: IMMEDIATE > MAILBOX > VSYNC
|
||||
[[nodiscard]] auto bestPresentMode(bool vsync) const -> SDL_GPUPresentMode;
|
||||
|
||||
SDL_Window* window_ = nullptr;
|
||||
SDL_GPUDevice* device_ = nullptr;
|
||||
SDL_GPUGraphicsPipeline* pipeline_ = nullptr; // PostFX pass (→ swapchain o → postfx_texture_)
|
||||
SDL_GPUGraphicsPipeline* crtpi_pipeline_ = nullptr; // CrtPi pass (→ swapchain directo, sin SS)
|
||||
SDL_GPUGraphicsPipeline* postfx_offscreen_pipeline_ = nullptr; // PostFX → postfx_texture_ (B8G8R8A8, solo con Lanczos)
|
||||
SDL_GPUGraphicsPipeline* upscale_pipeline_ = nullptr; // Upscale pass (solo con SS)
|
||||
SDL_GPUGraphicsPipeline* downscale_pipeline_ = nullptr; // Lanczos downscale (solo con SS + algo > 0)
|
||||
SDL_GPUGraphicsPipeline* pipeline_ = nullptr; // PostFX pass → swapchain
|
||||
SDL_GPUGraphicsPipeline* crtpi_pipeline_ = nullptr; // CrtPi pass → swapchain
|
||||
SDL_GPUGraphicsPipeline* upscale_pipeline_ = nullptr; // Upscale per al pas de resolució interna
|
||||
SDL_GPUTexture* scene_texture_ = nullptr; // Canvas del joc (game_width_ × game_height_)
|
||||
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* internal_texture_ = nullptr; // Resolució interna ampliada (game·N × game·N), si N>1
|
||||
SDL_GPUTransferBuffer* upload_buffer_ = nullptr;
|
||||
SDL_GPUSampler* sampler_ = nullptr; // NEAREST
|
||||
SDL_GPUSampler* linear_sampler_ = nullptr; // LINEAR
|
||||
SDL_GPUSampler* linear_sampler_ = nullptr; // LINEAR (per texture_filter_linear_)
|
||||
|
||||
PostFXUniforms uniforms_{.vignette_strength = 0.6F, .chroma_strength = 0.15F, .scanline_strength = 0.7F, .screen_height = 200.0F, .pixel_scale = 1.0F, .oversample = 1.0F};
|
||||
CrtPiUniforms crtpi_uniforms_{.scanline_weight = 6.0F, .scanline_gap_brightness = 0.12F, .bloom_factor = 3.5F, .input_gamma = 2.4F, .output_gamma = 2.2F, .mask_brightness = 0.80F, .curvature_x = 0.05F, .curvature_y = 0.10F, .mask_type = 2, .enable_scanlines = 1, .enable_multisample = 1, .enable_gamma = 1};
|
||||
PostFXUniforms uniforms_{
|
||||
.vignette_strength = 0.6F,
|
||||
.chroma_min = 0.15F,
|
||||
.scanline_strength = 0.7F,
|
||||
.screen_height = 200.0F,
|
||||
.mask_strength = 0.0F,
|
||||
.gamma_strength = 0.0F,
|
||||
.curvature = 0.0F,
|
||||
.bleeding = 0.0F,
|
||||
.pixel_scale = 1.0F,
|
||||
.time = 0.0F,
|
||||
.flicker = 0.0F,
|
||||
.chroma_max = 0.15F,
|
||||
.scan_dark_ratio = 0.333F,
|
||||
.scan_dark_floor = 0.42F,
|
||||
.scan_edge_soft = 1.0F,
|
||||
.pad3 = 0.0F};
|
||||
CrtPiUniforms crtpi_uniforms_{
|
||||
.scanline_weight = 6.0F,
|
||||
.scanline_gap_brightness = 0.12F,
|
||||
.bloom_factor = 3.5F,
|
||||
.input_gamma = 2.4F,
|
||||
.output_gamma = 2.2F,
|
||||
.mask_brightness = 0.80F,
|
||||
.curvature_x = 0.05F,
|
||||
.curvature_y = 0.10F,
|
||||
.mask_type = 2,
|
||||
.enable_scanlines = 1,
|
||||
.enable_multisample = 1,
|
||||
.enable_gamma = 1,
|
||||
.enable_curvature = 0,
|
||||
.enable_sharper = 0,
|
||||
.texture_width = 0.0F,
|
||||
.texture_height = 0.0F};
|
||||
ShaderType active_shader_ = ShaderType::POSTFX; // Shader de post-procesado activo
|
||||
|
||||
int game_width_ = 0; // Dimensions originals del canvas
|
||||
int game_height_ = 0;
|
||||
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 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 preferred_driver_; // Driver preferido; vacío = auto (SDL elige)
|
||||
bool is_initialized_ = false;
|
||||
bool vsync_ = true;
|
||||
bool integer_scale_ = false;
|
||||
bool linear_upscale_ = false; // Upscale NEAREST (false) o LINEAR (true)
|
||||
Options::ScalingMode scaling_mode_ = Options::ScalingMode::INTEGER;
|
||||
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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
DisableFormat: true
|
||||
SortIncludes: Never
|
||||
@@ -0,0 +1,4 @@
|
||||
# source/core/rendering/sdl3gpu/spv/.clang-tidy
|
||||
Checks: '-*'
|
||||
WarningsAsErrors: ''
|
||||
HeaderFilterRegex: ''
|
||||
@@ -2,13 +2,16 @@
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
#include "game/options.hpp"
|
||||
|
||||
namespace Rendering {
|
||||
|
||||
/** @brief Identificador del shader de post-procesado activo */
|
||||
enum class ShaderType { POSTFX,
|
||||
enum class ShaderType : std::uint8_t { POSTFX,
|
||||
CRTPI };
|
||||
|
||||
/**
|
||||
@@ -18,12 +21,19 @@ namespace Rendering {
|
||||
struct PostFXParams {
|
||||
float vignette = 0.0F; // Intensidad de la viñeta
|
||||
float scanlines = 0.0F; // Intensidad de las scanlines
|
||||
float chroma = 0.0F; // Aberración cromática
|
||||
// Aberració cromàtica — varia entre min i max via sinusoidal; si coincideixen
|
||||
// queda estàtica. min > 0 garanteix que la imatge mai sigui lliure de chroma.
|
||||
float chroma_min = 0.0F;
|
||||
float chroma_max = 0.0F;
|
||||
float mask = 0.0F; // Máscara de fósforo RGB
|
||||
float gamma = 0.0F; // Corrección gamma (blend 0=off, 1=full)
|
||||
float curvature = 0.0F; // Curvatura barrel CRT
|
||||
float bleeding = 0.0F; // Sangrado de color NTSC
|
||||
float flicker = 0.0F; // Parpadeo de fósforo CRT ~50 Hz
|
||||
// Forma de les scanlines — 3 subpíxels per fila lògica per defecte.
|
||||
float scan_dark_ratio = 0.333F; // fracció obscura (1/3)
|
||||
float scan_dark_floor = 0.42F; // brillantor subfila fosca
|
||||
float scan_edge_soft = 1.0F; // 0 = step dur; 1 = suavitzat 1 px físic
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -105,37 +115,9 @@ namespace Rendering {
|
||||
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*/) {}
|
||||
|
||||
/**
|
||||
* @brief Establece el factor de supersampling (1 = off, 3 = 3× SS)
|
||||
* Con factor > 1, la textura GPU se crea a game×factor resolución y
|
||||
* las scanlines se hornean en CPU (uploadPixels). El sampler usa LINEAR.
|
||||
*/
|
||||
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).
|
||||
* 0 = bilinear legacy (comportamiento actual, sin textura intermedia),
|
||||
* 1 = Lanczos2 (ventana 2, ~25 muestras), 2 = Lanczos3 (ventana 3, ~49 muestras).
|
||||
*/
|
||||
virtual void setDownscaleAlgo(int /*algo*/) {}
|
||||
[[nodiscard]] virtual auto getDownscaleAlgo() const -> int { return 0; }
|
||||
|
||||
/**
|
||||
* @brief Devuelve las dimensiones de la textura de supersampling.
|
||||
* @return Par (ancho, alto) en píxeles; (0, 0) si SS está desactivado.
|
||||
*/
|
||||
[[nodiscard]] virtual auto getSsTextureSize() const -> std::pair<int, int> { return {0, 0}; }
|
||||
virtual void setScalingMode(Options::ScalingMode /*mode*/) {}
|
||||
|
||||
/**
|
||||
* @brief Verifica si el backend está usando aceleración por hardware
|
||||
@@ -175,13 +157,20 @@ namespace Rendering {
|
||||
* @brief Activa/desactiva estirament vertical 4:3 (200→240 línies efectives).
|
||||
* Només afecta el viewport, no les textures ni els shaders.
|
||||
*/
|
||||
virtual void setStretch4_3(bool /*enabled*/) {}
|
||||
[[nodiscard]] virtual auto isStretch4_3() const -> bool { return false; }
|
||||
virtual void setStretch43(bool /*enabled*/) {}
|
||||
[[nodiscard]] virtual auto isStretch43() 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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "core/rendering/text.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
@@ -8,26 +9,25 @@
|
||||
#include <sstream>
|
||||
#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)
|
||||
struct rgb;
|
||||
extern unsigned char* LoadGif(unsigned char* data, unsigned short* w, unsigned short* h);
|
||||
// NOLINTNEXTLINE(readability-identifier-naming) — exportat per external/gif.h, no controlem el nom.
|
||||
extern auto LoadGif(unsigned char* data, unsigned short* w, unsigned short* h) -> unsigned char*;
|
||||
|
||||
Text::Text(const char* fnt_file, const char* gif_file) {
|
||||
loadBitmap(gif_file);
|
||||
loadFont(fnt_file);
|
||||
}
|
||||
|
||||
Text::~Text() {
|
||||
if (bitmap_) free(bitmap_);
|
||||
}
|
||||
|
||||
// --- UTF-8 ---
|
||||
|
||||
auto Text::nextCodepoint(const char*& ptr) -> uint32_t {
|
||||
auto byte = static_cast<uint8_t>(*ptr);
|
||||
if (byte == 0) return 0;
|
||||
if (byte == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
uint32_t cp = 0;
|
||||
int extra = 0;
|
||||
@@ -51,7 +51,9 @@ auto Text::nextCodepoint(const char*& ptr) -> uint32_t {
|
||||
ptr++;
|
||||
for (int i = 0; i < extra; i++) {
|
||||
auto cont = static_cast<uint8_t>(*ptr);
|
||||
if ((cont & 0xC0) != 0x80) return 0xFFFD;
|
||||
if ((cont & 0xC0) != 0x80) {
|
||||
return 0xFFFD;
|
||||
}
|
||||
cp = (cp << 6) | (cont & 0x3F);
|
||||
ptr++;
|
||||
}
|
||||
@@ -62,47 +64,47 @@ auto Text::nextCodepoint(const char*& ptr) -> uint32_t {
|
||||
// --- Càrrega de font ---
|
||||
|
||||
void Text::loadFont(const char* fnt_file) {
|
||||
int filesize = 0;
|
||||
char* buffer = file_getfilebuffer(fnt_file, filesize, true);
|
||||
if (!buffer) {
|
||||
auto buffer = ResourceHelper::loadFile(fnt_file);
|
||||
if (buffer.empty()) {
|
||||
std::cerr << "Text: unable to load font file: " << fnt_file << '\n';
|
||||
return;
|
||||
}
|
||||
|
||||
std::istringstream stream(std::string(buffer, filesize));
|
||||
free(buffer);
|
||||
std::istringstream stream(std::string(reinterpret_cast<const char*>(buffer.data()), buffer.size()));
|
||||
|
||||
std::string line;
|
||||
int glyph_index = 0;
|
||||
|
||||
while (std::getline(stream, line)) {
|
||||
// Ignora comentaris i línies buides
|
||||
if (line.empty() || line[0] == '#') continue;
|
||||
if (line.empty() || line[0] == '#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Elimina comentaris inline
|
||||
auto comment_pos = line.find('#');
|
||||
if (comment_pos != std::string::npos) {
|
||||
line = line.substr(0, comment_pos);
|
||||
line.resize(comment_pos);
|
||||
}
|
||||
|
||||
// Parseja directives
|
||||
if (line.find("box_width") == 0) {
|
||||
if (line.starts_with("box_width")) {
|
||||
sscanf(line.c_str(), "box_width %d", &box_width_);
|
||||
continue;
|
||||
}
|
||||
if (line.find("box_height") == 0) {
|
||||
if (line.starts_with("box_height")) {
|
||||
sscanf(line.c_str(), "box_height %d", &box_height_);
|
||||
continue;
|
||||
}
|
||||
if (line.find("columns") == 0) {
|
||||
if (line.starts_with("columns")) {
|
||||
sscanf(line.c_str(), "columns %d", &columns_);
|
||||
continue;
|
||||
}
|
||||
if (line.find("cell_spacing") == 0) {
|
||||
if (line.starts_with("cell_spacing")) {
|
||||
sscanf(line.c_str(), "cell_spacing %d", &cell_spacing_);
|
||||
continue;
|
||||
}
|
||||
if (line.find("row_spacing") == 0) {
|
||||
if (line.starts_with("row_spacing")) {
|
||||
sscanf(line.c_str(), "row_spacing %d", &row_spacing_);
|
||||
continue;
|
||||
}
|
||||
@@ -128,80 +130,86 @@ void Text::loadFont(const char* fnt_file) {
|
||||
}
|
||||
|
||||
void Text::loadBitmap(const char* gif_file) {
|
||||
int filesize = 0;
|
||||
char* buffer = file_getfilebuffer(gif_file, filesize);
|
||||
if (!buffer) {
|
||||
auto buffer = ResourceHelper::loadFile(gif_file);
|
||||
if (buffer.empty()) {
|
||||
std::cerr << "Text: unable to load bitmap: " << gif_file << '\n';
|
||||
return;
|
||||
}
|
||||
|
||||
// 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 h = raw[8] | (raw[9] << 8);
|
||||
|
||||
unsigned short gw = 0, gh = 0;
|
||||
unsigned short gw = 0;
|
||||
unsigned short gh = 0;
|
||||
Uint8* pixels = LoadGif(raw, &gw, &gh);
|
||||
if (!pixels) {
|
||||
if (pixels == nullptr) {
|
||||
std::cerr << "Text: unable to decode GIF: " << gif_file << '\n';
|
||||
free(buffer);
|
||||
return;
|
||||
}
|
||||
|
||||
bitmap_width_ = w;
|
||||
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';
|
||||
}
|
||||
|
||||
// --- Renderitzat ---
|
||||
|
||||
void Text::draw(Uint32* pixel_data, int x, int y, const char* text, Uint32 color) const {
|
||||
if (!bitmap_ || !pixel_data) return;
|
||||
auto Text::resolveGlyph(uint32_t cp) const -> const GlyphInfo* {
|
||||
auto it = glyphs_.find(cp);
|
||||
if (it != glyphs_.end()) {
|
||||
return &it->second;
|
||||
}
|
||||
it = glyphs_.find('?');
|
||||
return (it != glyphs_.end()) ? &it->second : nullptr;
|
||||
}
|
||||
|
||||
void Text::blitGlyph(Uint32* pixel_data, int dst_x, int dst_y, const GlyphInfo& glyph, Uint32 color, int clip_x_min, int clip_x_max, int clip_y_min, int clip_y_max) const {
|
||||
const int GY_START = std::max(0, clip_y_min - dst_y);
|
||||
const int GY_END = std::min(box_height_, clip_y_max - dst_y);
|
||||
const int GX_START = std::max(0, clip_x_min - dst_x);
|
||||
const int GX_END = std::min(glyph.w, clip_x_max - dst_x);
|
||||
for (int gy = GY_START; gy < GY_END; gy++) {
|
||||
const int SRC_Y = glyph.y + gy;
|
||||
if (SRC_Y >= bitmap_height_) {
|
||||
continue;
|
||||
}
|
||||
const int DST_ROW = dst_y + gy;
|
||||
for (int gx = GX_START; gx < GX_END; gx++) {
|
||||
const int SRC_X = glyph.x + gx;
|
||||
if (SRC_X >= bitmap_width_) {
|
||||
continue;
|
||||
}
|
||||
const Uint8 PIXEL = bitmap_[SRC_X + (SRC_Y * bitmap_width_)];
|
||||
if (PIXEL != 0) {
|
||||
pixel_data[(dst_x + gx) + (DST_ROW * SCREEN_WIDTH)] = color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Text::draw(Uint32* pixel_data, int x, int y, const char* text, Uint32 color) const {
|
||||
if (bitmap_.empty() || (pixel_data == nullptr)) {
|
||||
return;
|
||||
}
|
||||
const char* ptr = text;
|
||||
int cursor_x = x;
|
||||
|
||||
while (*ptr) {
|
||||
while (*ptr != 0) {
|
||||
uint32_t cp = nextCodepoint(ptr);
|
||||
if (cp == 0) break;
|
||||
|
||||
auto it = glyphs_.find(cp);
|
||||
if (it == glyphs_.end()) {
|
||||
it = glyphs_.find('?');
|
||||
if (it == glyphs_.end()) {
|
||||
if (cp == 0) {
|
||||
break;
|
||||
}
|
||||
const GlyphInfo* glyph = resolveGlyph(cp);
|
||||
if (glyph == nullptr) {
|
||||
cursor_x += box_width_;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const auto& glyph = it->second;
|
||||
|
||||
// Pinta glifo pixel a pixel
|
||||
for (int gy = 0; gy < box_height_; gy++) {
|
||||
int dst_y = y + gy;
|
||||
if (dst_y < 0 || dst_y >= SCREEN_HEIGHT) continue;
|
||||
|
||||
for (int gx = 0; gx < glyph.w; gx++) {
|
||||
int dst_x = cursor_x + gx;
|
||||
if (dst_x < 0 || dst_x >= SCREEN_WIDTH) continue;
|
||||
|
||||
int src_x = glyph.x + gx;
|
||||
int src_y = glyph.y + gy;
|
||||
|
||||
if (src_x >= bitmap_width_ || src_y >= bitmap_height_) continue;
|
||||
|
||||
Uint8 pixel = bitmap_[src_x + src_y * bitmap_width_];
|
||||
// Píxel no transparent (índex 0 és fons típicament)
|
||||
if (pixel != 0) {
|
||||
pixel_data[dst_x + dst_y * SCREEN_WIDTH] = color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cursor_x += glyph.w + 1; // +1 kerning
|
||||
blitGlyph(pixel_data, cursor_x, y, *glyph, color, 0, SCREEN_WIDTH, 0, SCREEN_HEIGHT);
|
||||
cursor_x += glyph->w + 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,152 +220,82 @@ 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 {
|
||||
if (!bitmap_ || !pixel_data) return;
|
||||
|
||||
// Descart ràpid si el glifo sencer cau fora verticalment
|
||||
if (y + box_height_ <= clip_y_min || y >= clip_y_max) return;
|
||||
|
||||
if (bitmap_.empty() || (pixel_data == nullptr)) {
|
||||
return;
|
||||
}
|
||||
if (y + box_height_ <= clip_y_min || y >= clip_y_max) {
|
||||
return;
|
||||
}
|
||||
const int X_MIN = std::max(0, clip_x_min);
|
||||
const int X_MAX = std::min(SCREEN_WIDTH, clip_x_max);
|
||||
const int Y_MIN = std::max(0, clip_y_min);
|
||||
const int Y_MAX = std::min(SCREEN_HEIGHT, clip_y_max);
|
||||
const char* ptr = text;
|
||||
int cursor_x = x;
|
||||
|
||||
while (*ptr) {
|
||||
while (*ptr != 0) {
|
||||
uint32_t cp = nextCodepoint(ptr);
|
||||
if (cp == 0) break;
|
||||
|
||||
auto it = glyphs_.find(cp);
|
||||
if (it == glyphs_.end()) {
|
||||
it = glyphs_.find('?');
|
||||
if (it == glyphs_.end()) {
|
||||
if (cp == 0) {
|
||||
break;
|
||||
}
|
||||
const GlyphInfo* glyph = resolveGlyph(cp);
|
||||
if (glyph == nullptr) {
|
||||
cursor_x += box_width_;
|
||||
continue;
|
||||
}
|
||||
if (cursor_x + glyph->w > X_MIN && cursor_x < X_MAX) {
|
||||
blitGlyph(pixel_data, cursor_x, y, *glyph, color, X_MIN, X_MAX, Y_MIN, Y_MAX);
|
||||
}
|
||||
|
||||
const auto& glyph = it->second;
|
||||
|
||||
// Si el glifo està completament fora del clip horitzontal, salta
|
||||
if (cursor_x + glyph.w <= clip_x_min || cursor_x >= clip_x_max) {
|
||||
cursor_x += glyph.w + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
for (int gy = 0; gy < box_height_; gy++) {
|
||||
int dst_y = y + gy;
|
||||
if (dst_y < 0 || dst_y >= SCREEN_HEIGHT) continue;
|
||||
if (dst_y < clip_y_min || dst_y >= clip_y_max) continue;
|
||||
|
||||
for (int gx = 0; gx < glyph.w; gx++) {
|
||||
int dst_x = cursor_x + gx;
|
||||
if (dst_x < 0 || dst_x >= SCREEN_WIDTH) continue;
|
||||
if (dst_x < clip_x_min || dst_x >= clip_x_max) continue;
|
||||
|
||||
int src_x = glyph.x + gx;
|
||||
int src_y = glyph.y + gy;
|
||||
if (src_x >= bitmap_width_ || src_y >= bitmap_height_) continue;
|
||||
|
||||
Uint8 pixel = bitmap_[src_x + src_y * bitmap_width_];
|
||||
if (pixel != 0) {
|
||||
pixel_data[dst_x + dst_y * SCREEN_WIDTH] = color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cursor_x += glyph.w + 1;
|
||||
cursor_x += glyph->w + 1;
|
||||
}
|
||||
}
|
||||
|
||||
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 == nullptr)) {
|
||||
return;
|
||||
}
|
||||
const char* ptr = text;
|
||||
int cursor_x = x;
|
||||
|
||||
while (*ptr) {
|
||||
while (*ptr != 0) {
|
||||
uint32_t cp = nextCodepoint(ptr);
|
||||
if (cp == 0) break;
|
||||
|
||||
auto it = glyphs_.find(cp);
|
||||
if (it == glyphs_.end()) {
|
||||
it = glyphs_.find('?');
|
||||
if (it == glyphs_.end()) {
|
||||
if (cp == 0) {
|
||||
break;
|
||||
}
|
||||
const GlyphInfo* glyph = resolveGlyph(cp);
|
||||
if (glyph == nullptr) {
|
||||
cursor_x += cell_w;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const auto& glyph = it->second;
|
||||
// Centra el glif dins la cel·la
|
||||
int glyph_x = cursor_x + (cell_w - glyph.w) / 2;
|
||||
|
||||
for (int gy = 0; gy < box_height_; gy++) {
|
||||
int dst_y = y + gy;
|
||||
if (dst_y < 0 || dst_y >= SCREEN_HEIGHT) continue;
|
||||
|
||||
for (int gx = 0; gx < glyph.w; gx++) {
|
||||
int dst_x = glyph_x + gx;
|
||||
if (dst_x < 0 || dst_x >= SCREEN_WIDTH) continue;
|
||||
|
||||
int src_x = glyph.x + gx;
|
||||
int src_y = glyph.y + gy;
|
||||
if (src_x >= bitmap_width_ || src_y >= bitmap_height_) continue;
|
||||
|
||||
Uint8 pixel = bitmap_[src_x + src_y * bitmap_width_];
|
||||
if (pixel != 0) {
|
||||
pixel_data[dst_x + dst_y * SCREEN_WIDTH] = color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const int GLYPH_X = cursor_x + ((cell_w - glyph->w) / 2);
|
||||
blitGlyph(pixel_data, GLYPH_X, y, *glyph, color, 0, SCREEN_WIDTH, 0, SCREEN_HEIGHT);
|
||||
cursor_x += cell_w;
|
||||
}
|
||||
}
|
||||
|
||||
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 == nullptr)) {
|
||||
return;
|
||||
}
|
||||
const char* ptr = text;
|
||||
int cursor_x = x;
|
||||
bool first = true;
|
||||
|
||||
while (*ptr) {
|
||||
while (*ptr != 0) {
|
||||
uint32_t cp = nextCodepoint(ptr);
|
||||
if (cp == 0) break;
|
||||
|
||||
auto it = glyphs_.find(cp);
|
||||
if (it == glyphs_.end()) {
|
||||
it = glyphs_.find('?');
|
||||
if (it == glyphs_.end()) {
|
||||
if (!first) cursor_x += 1;
|
||||
if (cp == 0) {
|
||||
break;
|
||||
}
|
||||
if (!first) {
|
||||
cursor_x += 1; // kerning
|
||||
}
|
||||
const GlyphInfo* glyph = resolveGlyph(cp);
|
||||
if (glyph == nullptr) {
|
||||
cursor_x += box_width_;
|
||||
first = false;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const auto& glyph = it->second;
|
||||
bool is_digit = (cp >= '0' && cp <= '9');
|
||||
|
||||
if (!first) cursor_x += 1; // kerning
|
||||
|
||||
int glyph_x = is_digit ? cursor_x + (digit_cell_w - glyph.w) / 2 : cursor_x;
|
||||
|
||||
for (int gy = 0; gy < box_height_; gy++) {
|
||||
int dst_y = y + gy;
|
||||
if (dst_y < 0 || dst_y >= SCREEN_HEIGHT) continue;
|
||||
for (int gx = 0; gx < glyph.w; gx++) {
|
||||
int dst_x = glyph_x + gx;
|
||||
if (dst_x < 0 || dst_x >= SCREEN_WIDTH) continue;
|
||||
int src_x = glyph.x + gx;
|
||||
int src_y = glyph.y + gy;
|
||||
if (src_x >= bitmap_width_ || src_y >= bitmap_height_) continue;
|
||||
Uint8 pixel = bitmap_[src_x + src_y * bitmap_width_];
|
||||
if (pixel != 0) {
|
||||
pixel_data[dst_x + dst_y * SCREEN_WIDTH] = color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cursor_x += is_digit ? digit_cell_w : glyph.w;
|
||||
const bool IS_DIGIT = (cp >= '0' && cp <= '9');
|
||||
const int GLYPH_X = IS_DIGIT ? cursor_x + ((digit_cell_w - glyph->w) / 2) : cursor_x;
|
||||
blitGlyph(pixel_data, GLYPH_X, y, *glyph, color, 0, SCREEN_WIDTH, 0, SCREEN_HEIGHT);
|
||||
cursor_x += IS_DIGIT ? digit_cell_w : glyph->w;
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
@@ -366,32 +304,41 @@ auto Text::widthMonoDigits(const char* text, int digit_cell_w) const -> int {
|
||||
const char* ptr = text;
|
||||
int w = 0;
|
||||
bool first = true;
|
||||
while (*ptr) {
|
||||
while (*ptr != 0) {
|
||||
uint32_t cp = nextCodepoint(ptr);
|
||||
if (cp == 0) break;
|
||||
if (!first) w += 1; // kerning
|
||||
if (cp == 0) {
|
||||
break;
|
||||
}
|
||||
if (!first) {
|
||||
w += 1; // kerning
|
||||
}
|
||||
first = false;
|
||||
bool is_digit = (cp >= '0' && cp <= '9');
|
||||
if (is_digit) {
|
||||
w += digit_cell_w;
|
||||
} else {
|
||||
auto it = glyphs_.find(cp);
|
||||
if (it == glyphs_.end()) it = glyphs_.find('?');
|
||||
if (it != glyphs_.end())
|
||||
if (it == glyphs_.end()) {
|
||||
it = glyphs_.find('?');
|
||||
}
|
||||
if (it != glyphs_.end()) {
|
||||
w += it->second.w;
|
||||
else
|
||||
} else {
|
||||
w += box_width_;
|
||||
}
|
||||
}
|
||||
}
|
||||
return w;
|
||||
}
|
||||
|
||||
auto Text::widthMono(const char* text, int cell_w) const -> int {
|
||||
auto Text::widthMono(const char* text, int cell_w) -> int {
|
||||
const char* ptr = text;
|
||||
int count = 0;
|
||||
while (*ptr) {
|
||||
while (*ptr != 0) {
|
||||
uint32_t cp = nextCodepoint(ptr);
|
||||
if (cp == 0) break;
|
||||
if (cp == 0) {
|
||||
break;
|
||||
}
|
||||
count++;
|
||||
}
|
||||
return count * cell_w;
|
||||
@@ -402,16 +349,20 @@ auto Text::width(const char* text) const -> int {
|
||||
int w = 0;
|
||||
bool first = true;
|
||||
|
||||
while (*ptr) {
|
||||
while (*ptr != 0) {
|
||||
uint32_t cp = nextCodepoint(ptr);
|
||||
if (cp == 0) break;
|
||||
if (cp == 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
auto it = glyphs_.find(cp);
|
||||
if (it == glyphs_.end()) {
|
||||
it = glyphs_.find('?');
|
||||
}
|
||||
|
||||
if (!first) w += 1; // kerning
|
||||
if (!first) {
|
||||
w += 1; // kerning
|
||||
}
|
||||
first = false;
|
||||
|
||||
if (it != glyphs_.end()) {
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
class Text {
|
||||
public:
|
||||
Text(const char* fnt_file, const char* gif_file);
|
||||
~Text();
|
||||
|
||||
// Pinta texto sobre un buffer ARGB de 320x200
|
||||
void draw(Uint32* pixel_data, int x, int y, const char* text, Uint32 color) const;
|
||||
@@ -28,7 +28,7 @@ class Text {
|
||||
// Calcula ancho en píxeles d'un text
|
||||
[[nodiscard]] auto width(const char* text) const -> int;
|
||||
// Amplada mono: nombre de codepoints × cell_w
|
||||
[[nodiscard]] auto widthMono(const char* text, int cell_w) const -> int;
|
||||
[[nodiscard]] static auto widthMono(const char* text, int cell_w) -> int;
|
||||
// Amplada mono-dígits: amplada natural, però substituint els dígits per digit_cell_w
|
||||
[[nodiscard]] auto widthMonoDigits(const char* text, int digit_cell_w) const -> int;
|
||||
[[nodiscard]] auto charHeight() const -> int { return box_height_; }
|
||||
@@ -46,7 +46,7 @@ class Text {
|
||||
int cell_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_height_{0};
|
||||
|
||||
@@ -57,6 +57,13 @@ class Text {
|
||||
void loadFont(const char* fnt_file);
|
||||
void loadBitmap(const char* gif_file);
|
||||
|
||||
// Resolt un codepoint al GlyphInfo corresponent o al fallback '?'.
|
||||
// Retorna nullptr si ni el codepoint ni el fallback existeixen.
|
||||
[[nodiscard]] auto resolveGlyph(uint32_t cp) const -> const GlyphInfo*;
|
||||
// Pinta un glif a (dst_x, dst_y) amb clipping per finestra.
|
||||
// Si la finestra és tota la pantalla, passar clip_x_min=0, clip_x_max=SCREEN_WIDTH, idem y.
|
||||
void blitGlyph(Uint32* pixel_data, int dst_x, int dst_y, const GlyphInfo& glyph, Uint32 color, int clip_x_min, int clip_x_max, int clip_y_min, int clip_y_max) const;
|
||||
|
||||
static constexpr int SCREEN_WIDTH = 320;
|
||||
static constexpr int SCREEN_HEIGHT = 200;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
#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).
|
||||
// NOLINTBEGIN(readability-identifier-naming) — símbols externs de gif.h.
|
||||
extern auto LoadGif(unsigned char* data, unsigned short* w, unsigned short* h) -> unsigned char*;
|
||||
extern auto LoadPalette(unsigned char* data) -> unsigned char*;
|
||||
// NOLINTEND(readability-identifier-naming)
|
||||
|
||||
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* {
|
||||
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* {
|
||||
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() {
|
||||
const 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";
|
||||
}
|
||||
|
||||
void Cache::stepEachInList(List::Type type, const std::function<void()>& clear_fn, LoadStage next, const std::function<void(size_t)>& load_fn) {
|
||||
auto items = List::get()->getListByType(type);
|
||||
if (stage_index_ == 0) {
|
||||
clear_fn();
|
||||
}
|
||||
if (stage_index_ >= items.size()) {
|
||||
stage_ = next;
|
||||
stage_index_ = 0;
|
||||
return;
|
||||
}
|
||||
load_fn(stage_index_++);
|
||||
}
|
||||
|
||||
void Cache::stepTextFiles() {
|
||||
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());
|
||||
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";
|
||||
return;
|
||||
}
|
||||
loadOneTextFile(stage_index_++);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
while (stage_ != LoadStage::DONE) {
|
||||
switch (stage_) {
|
||||
case LoadStage::MUSICS:
|
||||
stepEachInList(List::Type::MUSIC, [this] { musics_.clear(); }, LoadStage::SOUNDS, [this](size_t i) { loadOneMusic(i); });
|
||||
break;
|
||||
case LoadStage::SOUNDS:
|
||||
stepEachInList(List::Type::SOUND, [this] { sounds_.clear(); }, LoadStage::BITMAPS, [this](size_t i) { loadOneSound(i); });
|
||||
break;
|
||||
case LoadStage::BITMAPS:
|
||||
stepEachInList(List::Type::BITMAP, [this] { surfaces_.clear(); }, LoadStage::TEXT_FILES, [this](size_t i) { loadOneBitmap(i); });
|
||||
break;
|
||||
case LoadStage::TEXT_FILES:
|
||||
stepTextFiles();
|
||||
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* 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, 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* 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, 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
|
||||
@@ -0,0 +1,78 @@
|
||||
#pragma once
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/resources/resource_list.hpp"
|
||||
#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*;
|
||||
auto getSound(const std::string& name) -> Ja::Sound*;
|
||||
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 : std::uint8_t {
|
||||
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);
|
||||
void stepEachInList(List::Type type, const std::function<void()>& clear_fn, LoadStage next, const std::function<void(size_t)>& load_fn);
|
||||
void stepTextFiles();
|
||||
|
||||
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
|
||||
@@ -0,0 +1,71 @@
|
||||
#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_obj;
|
||||
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(Jf::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_obj.loadPack(pack_file);
|
||||
|
||||
if (pack_loaded) {
|
||||
std::cout << "ResourceHelper: pack loaded (" << pack_obj.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_obj.clear();
|
||||
pack_loaded = false;
|
||||
}
|
||||
|
||||
auto loadFile(const std::string& relative_path) -> std::vector<uint8_t> {
|
||||
if (pack_loaded && pack_obj.hasResource(relative_path)) {
|
||||
return pack_obj.getResource(relative_path);
|
||||
}
|
||||
if (fallback_enabled) {
|
||||
return readFromDisk(relative_path);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
auto hasPack() -> bool {
|
||||
return pack_loaded;
|
||||
}
|
||||
|
||||
} // namespace ResourceHelper
|
||||
@@ -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 `Jf::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
|
||||
@@ -0,0 +1,121 @@
|
||||
#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
|
||||
@@ -0,0 +1,63 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#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 : std::uint8_t {
|
||||
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
|
||||
@@ -0,0 +1,221 @@
|
||||
#include "core/resources/resource_pack.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <numeric>
|
||||
|
||||
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).
|
||||
return std::accumulate(data.begin(), data.end(), uint32_t{0x12345678}, [](uint32_t acc, unsigned char b) { return ((acc << 5) + acc) + b; });
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
return std::all_of(fs::recursive_directory_iterator(directory),
|
||||
fs::recursive_directory_iterator{},
|
||||
[&](const fs::directory_entry& entry) {
|
||||
if (!entry.is_regular_file()) {
|
||||
return true;
|
||||
}
|
||||
std::string filepath = entry.path().string();
|
||||
std::string filename = fs::relative(entry.path(), directory).string();
|
||||
std::ranges::replace(filename, '\\', '/');
|
||||
return addFile(filename, filepath);
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
#pragma once
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// Forward declarations to keep this header light.
|
||||
namespace Ja {
|
||||
struct Music;
|
||||
struct Sound;
|
||||
void deleteMusic(Music* music);
|
||||
void deleteSound(Sound* sound);
|
||||
} // namespace Ja
|
||||
|
||||
namespace Resource {
|
||||
|
||||
struct MusicDeleter {
|
||||
void operator()(Ja::Music* music) const noexcept {
|
||||
if (music != nullptr) {
|
||||
Ja::deleteMusic(music);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
struct SoundDeleter {
|
||||
void operator()(Ja::Sound* sound) const noexcept {
|
||||
if (sound != nullptr) {
|
||||
Ja::deleteSound(sound);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
struct MusicResource {
|
||||
std::string name;
|
||||
std::unique_ptr<Ja::Music, MusicDeleter> music;
|
||||
};
|
||||
|
||||
struct SoundResource {
|
||||
std::string name;
|
||||
std::unique_ptr<Ja::Sound, 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
|
||||
@@ -3,314 +3,377 @@
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
|
||||
#include "core/audio/audio.hpp"
|
||||
#include "core/input/gamepad.hpp"
|
||||
#include "core/input/global_inputs.hpp"
|
||||
#include "core/input/key_config.hpp"
|
||||
#include "core/input/key_remap.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/jinput.hpp"
|
||||
#include "core/locale/locale.hpp"
|
||||
#include "core/rendering/menu.hpp"
|
||||
#include "core/rendering/overlay.hpp"
|
||||
#include "core/rendering/screen.hpp"
|
||||
#include "core/resources/resource_cache.hpp"
|
||||
#include "game/info.hpp"
|
||||
#include "game/modulegame.hpp"
|
||||
#include "game/modulesequence.hpp"
|
||||
#include "game/options.hpp"
|
||||
#include "game/scenes/banner_scene.hpp"
|
||||
#include "game/scenes/boot_loader_scene.hpp"
|
||||
#include "game/scenes/credits_scene.hpp"
|
||||
#include "game/scenes/intro_new_logo_scene.hpp"
|
||||
#include "game/scenes/intro_scene.hpp"
|
||||
#include "game/scenes/menu_scene.hpp"
|
||||
#include "game/scenes/mort_scene.hpp"
|
||||
#include "game/scenes/scene.hpp"
|
||||
#include "game/scenes/scene_registry.hpp"
|
||||
#include "game/scenes/secreta_scene.hpp"
|
||||
#include "game/scenes/slides_scene.hpp"
|
||||
|
||||
// Cheats del joc original — declarats a jinput.cpp
|
||||
extern void JI_moveCheats(Uint8 new_key);
|
||||
std::unique_ptr<Director> Director::instance;
|
||||
|
||||
Director* Director::instance_ = nullptr;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
auto Director::createNextScene() const -> std::unique_ptr<Scenes::Scene> {
|
||||
// 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() {
|
||||
instance_ = new Director();
|
||||
instance = std::unique_ptr<Director>(new Director());
|
||||
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() {
|
||||
Gamepad::destroy();
|
||||
delete instance_;
|
||||
instance_ = nullptr;
|
||||
instance.reset();
|
||||
}
|
||||
|
||||
auto Director::get() -> Director* {
|
||||
return instance_;
|
||||
return instance.get();
|
||||
}
|
||||
|
||||
void Director::togglePause() {
|
||||
paused_ = !paused_;
|
||||
if (paused_) {
|
||||
JA_PauseMusic();
|
||||
Audio::get()->pauseMusic();
|
||||
} else {
|
||||
JA_ResumeMusic();
|
||||
Audio::get()->resumeMusic();
|
||||
}
|
||||
}
|
||||
|
||||
void Director::run() {
|
||||
// Llança el game thread
|
||||
game_thread_ = std::thread(&Director::gameThreadFunc, this);
|
||||
void Director::setup() {
|
||||
// Els buffers són membres (director.hpp); només els inicialitzem.
|
||||
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
|
||||
// és el frame + overlay (es regenera cada iteració des de game_frame)
|
||||
Uint32 game_frame[320 * 200]{};
|
||||
Uint32 presentation_buffer[320 * 200]{};
|
||||
bool has_frame = false;
|
||||
void Director::applyRestart() {
|
||||
restart_requested_ = false;
|
||||
Audio::get()->stopMusic();
|
||||
Audio::get()->stopAllSounds();
|
||||
initGameContext();
|
||||
Info::ctx.num_piramide = 255;
|
||||
current_scene_.reset();
|
||||
game_state_ = 1;
|
||||
has_frame_ = false;
|
||||
Menu::close();
|
||||
Ji::setInputBlocked(false);
|
||||
}
|
||||
|
||||
constexpr Uint32 FRAME_MS_VSYNC = 16; // ~60 FPS amb VSync
|
||||
constexpr Uint32 FRAME_MS_NO_VSYNC = 4; // ~250 FPS sense VSync (límit superior)
|
||||
void Director::maybeStartTitleCredits() {
|
||||
static bool credits_triggered_ = false;
|
||||
if (credits_triggered_ || Info::ctx.num_piramide != 0) {
|
||||
return;
|
||||
}
|
||||
if (Options::game.show_title_credits) {
|
||||
Overlay::startCredits();
|
||||
}
|
||||
credits_triggered_ = true;
|
||||
}
|
||||
|
||||
// Bucle principal del director (no-bloquejant)
|
||||
while (!game_thread_done_ && !quit_requested_) {
|
||||
Uint32 frame_start = SDL_GetTicks();
|
||||
auto Director::tickActiveScene() -> bool {
|
||||
if (current_scene_ && (current_scene_->done() || Jg::quitting())) {
|
||||
game_state_ = current_scene_->nextState();
|
||||
current_scene_.reset();
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
Jd8::flip();
|
||||
std::memcpy(game_frame_, Jd8::getFramebuffer(), sizeof(game_frame_));
|
||||
has_frame_ = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
auto Director::iterate() -> bool {
|
||||
if (quit_requested_) {
|
||||
Jg::quitSignal();
|
||||
current_scene_.reset();
|
||||
return false;
|
||||
}
|
||||
if (restart_requested_) {
|
||||
applyRestart();
|
||||
}
|
||||
if (!context_initialized_) {
|
||||
initGameContext();
|
||||
context_initialized_ = true;
|
||||
}
|
||||
|
||||
constexpr Uint32 FRAME_MS_VSYNC = 16;
|
||||
constexpr Uint32 FRAME_MS_NO_VSYNC = 4;
|
||||
const Uint32 FRAME_START = SDL_GetTicks();
|
||||
|
||||
handleEvents();
|
||||
Gamepad::update();
|
||||
KeyRemap::update();
|
||||
GlobalInputs::handle();
|
||||
Mouse::updateCursorVisibility();
|
||||
Audio::update();
|
||||
|
||||
// Dispara els crèdits cinematogràfics la primera vegada que el joc
|
||||
// arriba al menú del títol (info::num_piramide == 0). Lectura no
|
||||
// atòmica d'un int global: race benigna, tard d'1 frame en el pitjor cas.
|
||||
static bool credits_triggered = false;
|
||||
if (!credits_triggered && info::num_piramide == 0) {
|
||||
Overlay::startCredits();
|
||||
credits_triggered = true;
|
||||
}
|
||||
maybeStartTitleCredits();
|
||||
|
||||
// Si l'overlay ja no bloqueja ESC (timeout), desbloquegem
|
||||
if (esc_blocked_ && !Overlay::isEscConsumed()) {
|
||||
esc_blocked_ = false;
|
||||
}
|
||||
|
||||
// Consumeix un frame nou si n'hi ha un disponible (no bloqueja).
|
||||
// Si estem en pausa, no consumim: el game thread es queda bloquejat a publishFrame.
|
||||
bool new_frame = false;
|
||||
if (!paused_) {
|
||||
std::lock_guard lock(mutex_);
|
||||
if (frame_ready_ && latest_frame_ != nullptr) {
|
||||
memcpy(game_frame, latest_frame_, sizeof(game_frame));
|
||||
frame_ready_ = false;
|
||||
frame_consumed_ = true;
|
||||
has_frame = true;
|
||||
new_frame = true;
|
||||
}
|
||||
}
|
||||
if (new_frame) {
|
||||
frame_consumed_cv_.notify_one(); // desbloqueja el joc
|
||||
}
|
||||
|
||||
// Presenta sempre: parteix del frame net del joc, afegeix overlay i envia
|
||||
if (has_frame) {
|
||||
memcpy(presentation_buffer, game_frame, sizeof(presentation_buffer));
|
||||
Screen::get()->present(presentation_buffer);
|
||||
}
|
||||
|
||||
// Límit de framerate segons VSync
|
||||
Uint32 target_ms = Options::video.vsync ? FRAME_MS_VSYNC : FRAME_MS_NO_VSYNC;
|
||||
Uint32 elapsed = SDL_GetTicks() - frame_start;
|
||||
if (elapsed < target_ms) {
|
||||
SDL_Delay(target_ms - elapsed);
|
||||
if (!tickActiveScene()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Assegura que el game thread ix (despertar-lo per si està esperant)
|
||||
quit_requested_ = true;
|
||||
JG_QuitSignal();
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
frame_consumed_ = true;
|
||||
if (has_frame_) {
|
||||
std::memcpy(presentation_buffer_, game_frame_, sizeof(presentation_buffer_));
|
||||
Screen::get()->present(presentation_buffer_);
|
||||
}
|
||||
frame_consumed_cv_.notify_all();
|
||||
|
||||
if (game_thread_.joinable()) {
|
||||
game_thread_.join();
|
||||
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) {
|
||||
SDL_Delay(TARGET_MS - ELAPSED);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void Director::teardown() {
|
||||
// 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();
|
||||
current_scene_.reset();
|
||||
}
|
||||
|
||||
void Director::run() {
|
||||
setup();
|
||||
while (true) {
|
||||
pollAllEvents();
|
||||
if (!iterate()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
teardown();
|
||||
}
|
||||
|
||||
void Director::pollAllEvents() {
|
||||
SDL_Event event;
|
||||
while (SDL_PollEvent(&event)) {
|
||||
handleEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
void Director::handleEvents() {
|
||||
SDL_Event event;
|
||||
while (SDL_PollEvent(&event)) {
|
||||
if (event.type == SDL_EVENT_QUIT) {
|
||||
JG_QuitSignal();
|
||||
requestQuit();
|
||||
}
|
||||
// Hot-plug de gamepad
|
||||
if (event.type == SDL_EVENT_GAMEPAD_ADDED || event.type == SDL_EVENT_GAMEPAD_REMOVED) {
|
||||
Gamepad::handleEvent(event);
|
||||
continue;
|
||||
}
|
||||
// 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
|
||||
auto Director::handleMenuEvent(const SDL_Event& event) -> bool {
|
||||
// Empassar-se el KEY_UP d'una tecla que el menú va consumir en KEY_DOWN.
|
||||
if (event.type == SDL_EVENT_KEY_UP && event.key.scancode >= 0 &&
|
||||
event.key.scancode < SDL_SCANCODE_COUNT && menu_keys_held_[event.key.scancode]) {
|
||||
menu_keys_held_[event.key.scancode] = false;
|
||||
continue;
|
||||
return true;
|
||||
}
|
||||
// Captura de tecla (remapeig al menú): intercepta KEY_DOWN abans de tot
|
||||
if (Menu::isCapturing() && event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat) {
|
||||
const bool KEY_DOWN = event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat;
|
||||
// Captura de tecla (remapeig al menú): intercepta KEY_DOWN abans de tot.
|
||||
if (Menu::isCapturing() && KEY_DOWN) {
|
||||
Menu::captureKey(event.key.scancode);
|
||||
menu_keys_held_[event.key.scancode] = true;
|
||||
continue;
|
||||
return true;
|
||||
}
|
||||
// Pausa: F11 (o tecla configurada) pausa/reprén la simulació
|
||||
if (event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat &&
|
||||
event.key.scancode == Options::keys_gui.pause_toggle) {
|
||||
// Pausa / menú toggle.
|
||||
if (KEY_DOWN && event.key.scancode == KeyConfig::scancode("pause_toggle")) {
|
||||
togglePause();
|
||||
Overlay::showNotification(paused_ ? Locale::get("notifications.pause") : Locale::get("notifications.resume"));
|
||||
menu_keys_held_[event.key.scancode] = true;
|
||||
continue;
|
||||
return true;
|
||||
}
|
||||
// Menú: F12 (o tecla configurada) obre/tanca el menú flotant
|
||||
if (event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat &&
|
||||
event.key.scancode == Options::keys_gui.menu_toggle) {
|
||||
if (KEY_DOWN && event.key.scancode == KeyConfig::scancode("menu_toggle")) {
|
||||
Menu::toggle();
|
||||
JI_SetInputBlocked(Menu::isOpen());
|
||||
Ji::setInputBlocked(Menu::isOpen());
|
||||
menu_keys_held_[event.key.scancode] = true;
|
||||
continue;
|
||||
return true;
|
||||
}
|
||||
// Si el menú està obert, consumeix tot l'input de teclat
|
||||
if (Menu::isOpen() && event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat) {
|
||||
// Si el menú està obert, consumeix tot l'input de teclat.
|
||||
if (Menu::isOpen() && KEY_DOWN) {
|
||||
if (event.key.scancode == SDL_SCANCODE_ESCAPE) {
|
||||
Menu::close();
|
||||
JI_SetInputBlocked(false);
|
||||
// Empassa l'ESC fins al release perquè el joc no la veja per polling
|
||||
Ji::setInputBlocked(false);
|
||||
esc_swallow_until_release_ = true;
|
||||
} else {
|
||||
Menu::handleKey(event.key.scancode);
|
||||
// El menú pot haver-se tancat (p.ex. Backspace al nivell arrel)
|
||||
if (!Menu::isOpen()) {
|
||||
JI_SetInputBlocked(false);
|
||||
Ji::setInputBlocked(false);
|
||||
}
|
||||
}
|
||||
menu_keys_held_[event.key.scancode] = true;
|
||||
continue;
|
||||
return true;
|
||||
}
|
||||
if (Menu::isOpen() && event.type == SDL_EVENT_KEY_UP) {
|
||||
continue; // no deixem passar KEY_UP al joc tampoc
|
||||
return true;
|
||||
}
|
||||
// Allibera el bloqueig d'ESC quan l'usuari la deixa anar
|
||||
return false;
|
||||
}
|
||||
|
||||
auto Director::handleEscapeEvent(const SDL_Event& event) -> bool {
|
||||
// Salta els crèdits amb qualsevol tecla que arribe al joc.
|
||||
if (event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat && Overlay::creditsActive()) {
|
||||
Overlay::cancelCredits();
|
||||
return true;
|
||||
}
|
||||
// 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_) {
|
||||
esc_swallow_until_release_ = false;
|
||||
continue;
|
||||
return true;
|
||||
}
|
||||
// ESC: interceptem KEY_DOWN per bloquejar-la ABANS que el joc la veja per polling
|
||||
// ESC KEY_DOWN: bloqueja per polling i decideix notificació vs eixida.
|
||||
if (event.type == SDL_EVENT_KEY_DOWN && event.key.scancode == SDL_SCANCODE_ESCAPE && !event.key.repeat) {
|
||||
esc_blocked_ = true; // Bloqueja ESC per polling immediatament
|
||||
esc_blocked_ = true;
|
||||
if (!Overlay::isEscConsumed()) {
|
||||
// Primera pulsació: mostra notificació
|
||||
Overlay::handleEscape();
|
||||
} else {
|
||||
// Segona pulsació: senyal d'eixida al joc
|
||||
esc_blocked_ = false;
|
||||
key_pressed_ = true;
|
||||
JG_QuitSignal();
|
||||
// Si estem en pausa, la desactivem (sense reprendre la música,
|
||||
// estem eixint): el game thread està bloquejat a publishFrame
|
||||
// i necessita que Director consumeixca frames per despertar-lo
|
||||
// i poder veure la senyal de quit.
|
||||
Jg::quitSignal();
|
||||
paused_ = false;
|
||||
}
|
||||
continue; // no processa més aquest event
|
||||
return true;
|
||||
}
|
||||
if (event.type == SDL_EVENT_KEY_UP) {
|
||||
if (event.key.scancode == SDL_SCANCODE_ESCAPE) {
|
||||
// Ja processat a KEY_DOWN, només deixem netejar el bloqueig
|
||||
// quan l'overlay faça timeout
|
||||
continue;
|
||||
} else {
|
||||
// Comprova si és una tecla GUI (no passa al joc)
|
||||
const auto sc = event.key.scancode;
|
||||
const bool is_gui_key = (sc == Options::keys_gui.dec_zoom ||
|
||||
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) {
|
||||
return false;
|
||||
}
|
||||
|
||||
void Director::handleEvent(const SDL_Event& event) {
|
||||
if (event.type == SDL_EVENT_QUIT) {
|
||||
Jg::quitSignal();
|
||||
requestQuit();
|
||||
}
|
||||
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);
|
||||
return;
|
||||
}
|
||||
if (handleMenuEvent(event)) {
|
||||
return;
|
||||
}
|
||||
if (handleEscapeEvent(event)) {
|
||||
return;
|
||||
}
|
||||
if (event.type == SDL_EVENT_KEY_UP && event.key.scancode != SDL_SCANCODE_ESCAPE) {
|
||||
const auto SC = event.key.scancode;
|
||||
if (!KeyConfig::isGuiKey(SC)) {
|
||||
key_pressed_ = true;
|
||||
JI_moveCheats(sc);
|
||||
}
|
||||
Ji::moveCheats(SC);
|
||||
}
|
||||
}
|
||||
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() {
|
||||
quit_requested_ = true;
|
||||
JG_QuitSignal();
|
||||
frame_consumed_cv_.notify_all();
|
||||
frame_produced_cv_.notify_all();
|
||||
Jg::quitSignal();
|
||||
}
|
||||
|
||||
void Director::requestRestart() {
|
||||
restart_requested_ = true;
|
||||
}
|
||||
|
||||
auto Director::consumeKeyPressed() -> bool {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -3,68 +3,102 @@
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
#include <memory>
|
||||
|
||||
// El Director és el thread principal que controla la presentació i els inputs.
|
||||
// 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
|
||||
// abans de donar-li via per produir el següent.
|
||||
#include "game/scenes/scene.hpp"
|
||||
|
||||
// El Director és l'únic thread del runtime. Cada iterate() fa input →
|
||||
// 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 {
|
||||
public:
|
||||
static void init();
|
||||
static void destroy();
|
||||
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();
|
||||
|
||||
// Invocat pel game thread des de JD8_Flip(). Bloqueja fins que el director
|
||||
// consumeix el frame i dona via per produir el següent.
|
||||
void publishFrame(Uint32* pixels);
|
||||
// Punts d'entrada compatibles amb SDL_AppInit / SDL_AppIterate /
|
||||
// SDL_AppEvent / SDL_AppQuit. Permeten que el Director siga driven
|
||||
// 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();
|
||||
auto iterate() -> bool; // 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)
|
||||
void requestQuit();
|
||||
[[nodiscard]] auto isQuitRequested() const -> bool { return quit_requested_; }
|
||||
|
||||
// Consumeix el flag de "tecla polsada" (com l'antic JI_AnyKey)
|
||||
// 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)
|
||||
auto consumeKeyPressed() -> bool;
|
||||
|
||||
// Indica si ESC està bloquejada (el joc no l'ha de veure)
|
||||
auto isEscBlocked() const -> bool { return esc_blocked_ || esc_swallow_until_release_; }
|
||||
[[nodiscard]] 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();
|
||||
auto isPaused() const -> bool { return paused_; }
|
||||
[[nodiscard]] auto isPaused() const -> bool { return paused_; }
|
||||
|
||||
~Director();
|
||||
|
||||
private:
|
||||
Director() = default;
|
||||
~Director() = default;
|
||||
|
||||
static Director* instance_;
|
||||
static std::unique_ptr<Director> instance;
|
||||
|
||||
void gameThreadFunc();
|
||||
void handleEvents();
|
||||
void pollAllEvents(); // drenatge amb SDL_PollEvent, només per al bucle natiu
|
||||
|
||||
std::thread game_thread_;
|
||||
std::mutex mutex_;
|
||||
std::condition_variable frame_produced_cv_;
|
||||
std::condition_variable frame_consumed_cv_;
|
||||
// Inicialitza Info::ctx a partir de Options::game.* i comprova trick.ini.
|
||||
// Es crida una sola vegada des d'iterate() a la primera invocació.
|
||||
static void initGameContext();
|
||||
// Construeix l'escena apropiada segons game_state_ i Info::ctx.
|
||||
// Retorna nullptr si l'state actual no té escena registrada (bug).
|
||||
[[nodiscard]] auto createNextScene() const -> std::unique_ptr<Scenes::Scene>;
|
||||
// Helpers d'iterate() — extrets per reduir complexitat cognitiva.
|
||||
void applyRestart();
|
||||
static void maybeStartTitleCredits();
|
||||
auto tickActiveScene() -> bool; // true = continuar; false = sortir del loop
|
||||
|
||||
Uint32* latest_frame_{nullptr};
|
||||
bool frame_ready_{false};
|
||||
bool frame_consumed_{true};
|
||||
// Helpers d'handleEvent() — cada un retorna true si l'event s'ha consumit.
|
||||
auto handleMenuEvent(const SDL_Event& event) -> bool;
|
||||
auto handleEscapeEvent(const SDL_Event& event) -> bool;
|
||||
|
||||
// Buffers persistents entre iteracions. Abans eren locals a run(),
|
||||
// ara són membres perquè iterate() els pot reutilitzar sense tornar-los
|
||||
// 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> game_thread_done_{false};
|
||||
std::atomic<bool> restart_requested_{false};
|
||||
std::atomic<bool> key_pressed_{false};
|
||||
std::atomic<bool> esc_blocked_{false};
|
||||
std::atomic<bool> paused_{false};
|
||||
// Quan el menú tanca amb ESC, empassem-nos l'ESC fins que l'usuari la deixe anar,
|
||||
// per no fer eixir el joc al proper poll de JI_KeyPressed.
|
||||
// per no fer eixir el joc al proper poll de Ji::keyPressed.
|
||||
std::atomic<bool> esc_swallow_until_release_{false};
|
||||
// Tecles consumides pel menú (KEY_DOWN): el KEY_UP associat cal empassar-lo
|
||||
// per evitar que el joc (JI_AnyKey / JI_moveCheats) les veja quan el menú tanca.
|
||||
// per evitar que el joc (Ji::anyKey / Ji::moveCheats) les veja quan el menú tanca.
|
||||
bool menu_keys_held_[SDL_SCANCODE_COUNT]{};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# source/external/.clang-tidy
|
||||
Checks: '-*'
|
||||
WarningsAsErrors: ''
|
||||
HeaderFilterRegex: ''
|
||||
@@ -1,3 +1,4 @@
|
||||
// NOLINTBEGIN(clang-analyzer-unix.Malloc) — codi extern de tercers, no l'auditem
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
@@ -510,3 +511,4 @@ unsigned char* LoadGif(unsigned char *buffer, unsigned short* w, unsigned short*
|
||||
|
||||
fclose( gif_file );
|
||||
}*/
|
||||
// NOLINTEND(clang-analyzer-unix.Malloc)
|
||||
|
||||