Compare commits

..
32 Commits
Author SHA1 Message Date
JailDesigner 2cbf1bb6e0 chore(version): bump 0.39.0 -> 0.40.0 2026-05-08 23:00:06 +02:00
JailDesigner 8890fa9e1f Merge branch 'feat/player-join-and-game-over' 2026-05-08 22:59:47 +02:00
JailDesigner 23f7cb03a6 chore(data): restaura lives:3 a sam i morcus despres de proves 2026-05-08 22:59:43 +02:00
JailDesigner 486a440668 feat(game): skip de countdown amb JUMP/ATTACK quan numero <= 7 2026-05-08 22:20:39 +02:00
JailDesigner a717411edd feat(audio): musica acelerada en CONTINUE?, game_over.ogg, tick del countdown 2026-05-08 22:06:44 +02:00
JailDesigner 5e862ceadc fix(game): HUD lives desde YAML, prompt P1 en single-P2, countdown 9..0 configurable 2026-05-08 21:14:33 +02:00
JailDesignerandClaude Opus 4.7 78e164fce4 feat(game): CONTINUE? + GAME OVER + fade-to-LOGO (Fase 2)
Sub-FSM nova `GameOverFsm` (CONTINUE_PROMPT → GAME_OVER → FADE_OUT →
DONE) que viu en paral·lel a la `RoomFsm`. Es dispara quan tots els
jugadors queden DISABLED durant PLAYING, mostra el rètol "Continuar?"
amb countdown reusant el panel del room text, i si ningú pulsa START
acaba en "Fi del Joc" + fade negre i sol·licita la transició a LOGO.
Pulsar START durant CONTINUE? cancel·la el countdown i fa join (revive)
del jugador. Després de GAME OVER, el HUD passa a "ESPERA / PER JUGAR"
i el START queda bloquejat fins a la transició d'escena.

Els helpers d'animació del room text (X i altura del panel) ara reben
phase + elapsed_ms per paràmetre per poder ser reaprofitats per les
dues sub-FSMs sense duplicar la lògica visual.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:30:37 +02:00
JailDesignerandClaude Opus 4.7 c13b11c501 feat(player): join in progress + DISABLED en agotar vidas (Fase 1)
Habilita la regla 1 de la nova FSM de joc: durant PLAYING, qualsevol
slot DISABLED pot pulsar START per entrar a partida amb spawn aeri al
seu costat de la porta i vides reseteades. Quan un jugador esgota les
vides, `respawnFromDying` el deixa a DISABLED i publica `player_disabled`
al bus; GameScene i HUD reaccionen, deixant el slot disponible per a un
nou join. Afegeix prompts al HUD ("PULSA START / PER JUGAR" o
"ESPERA / PER JUGAR") segons l'estat de la RoomFsm.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:04:33 +02:00
JailDesigner 8921b9fd0c chore(version): bump 0.38.0 -> 0.39.0 2026-05-08 13:43:44 +02:00
JailDesigner 1d9fea28fe Merge branch 'feat/gameplay-tweaks'
Tweaks de jugabilitat persistents (gameplay.yaml a la carpeta de
sistema) amb tres flags i submenú "Jugabilitat" al ServiceMenu:
látec travessa sarcs, foc amic, col·lisió entre jugadors.
2026-05-08 13:43:24 +02:00
JailDesigner d0d5cb0e66 fix(player): players_collide també anul·la resolver i landing overlap 2026-05-08 13:40:30 +02:00
JailDesigner e25ccd02fd feat(menu): submenú "Jugabilitat" al menú de servei amb tres toggles
Nou apartat al ServiceMenu (entre Controls i Sistema) amb tres toggles
ON/OFF connectats a GameplayConfig: látec travessa sarcs, foc amic,
col·lisió entre jugadors. Cada canvi crida save() immediatament, així
l'estat persisteix entre sessions sense necessitat de "aplicar".

Locales ca/en: claus `tweaks` + tres `menu.items.*` corresponents.
2026-05-08 13:29:04 +02:00
JailDesigner 3635cebf25 feat(player): cabla flag players.collide_with_each_other
A `collidesWithWorld` saltem la branca actor↔actor quan el flag està
desactivat: els jugadors travessen els altres lliurement però segueixen
topant amb murs i sarcs. A `tryPushSnapBlockers` retornem early per la
mateixa raó: si no hi ha col·lisió entre jugadors, no hi ha ningú a
empentar quan el snap del corredor s'aplica.
2026-05-08 13:26:47 +02:00
JailDesigner cfbcdc236b feat(whip): cabla flag combat.friendly_fire a hitPlayerIfApplicable
Quan el flag està desactivat, el látigo no fa dany a altres jugadors
(l'owner ja quedava exclòs per la guarda existent). Default true:
manté el comportament actual.

Sense canvis als tests del whip — friendly fire ja no es cobreix
allà perquè els stubs de Player no porten estat real (vegeu el comentari
del top de test_whip_attack.cpp).
2026-05-08 13:24:33 +02:00
JailDesigner 7871d3930e feat(whip): cabla flag whip.damage_pierces_walls a computeDamageRect
Quan el flag està actiu (default), l'iteració píxel a píxel ignora
`Map::collides` i només respecta `insidePlayfield`. El mur extern,
doncs, sempre retalla — fora del playfield no hi ha res a colpejar.

Els tests existents passen amb la nova lògica perquè els seus setups
posen el damage_rect a corredors lliures (sense sarcs a la trajectòria),
i el test W2 segueix verificant el retall del playfield.
2026-05-08 13:22:52 +02:00
JailDesigner 932b457725 feat(config): afegeix GameplayConfig persistent amb versionatge defensiu
Singleton paral·lel a UiConfig però escrivible: viu a la carpeta de
sistema (gameplay.yaml). Defaults compilats: friendly_fire,
players_collide i whip_damage_pierces_walls a true. Davant qualsevol
irregularitat (fitxer absent/buit, parse error, version absent o
desconeguda, clau requerida mal tipada o absent), reseteja a defaults
i reescriu el fitxer en lloc de preservar estat parcial.

Cablejat al Director::start després de Options::loadFromFile. Encara
sense consumidors — els flags es cablejaran a WhipAttack i al sistema
de col·lisió de jugadors en commits posteriors.
2026-05-08 13:17:28 +02:00
JailDesigner e9f99a0542 fix(mummy): evita soft-lock per drift sub-pixel a HURT vora del mur 2026-05-08 12:07:18 +02:00
JailDesigner 0d58da536c chore(version): bump 0.37.0 -> 0.38.0 2026-05-08 11:16:22 +02:00
JailDesigner 583acb9053 fix(config): rutes per plataforma a createSystemFolder 2026-05-08 11:15:15 +02:00
JailDesigner babb111b6f chore(version): bump 0.36.0 -> 0.37.0 2026-05-08 09:59:23 +02:00
JailDesignerandClaude Opus 4.7 866e133aa9 Merge branch 'feat/logger'
Sistema de log unificat (Log:: wrapper sobre SDL_Log) substituint
els 176 std::cout/std::cerr dispersos per source/. API basada en
std::format, filtre runtime via AEE_LOG (sintaxi *:info,Tag:debug),
i marcadors literals via Log::raw().

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:01:09 +02:00
JailDesigner c910a16834 tune(whip): puja swing a 320ms (80ms × 4 frames) 2026-05-07 20:36:15 +02:00
JailDesigner 0ec1a2548a feat(mummy): so mummy_hurt en colpejada de fuet 2026-05-07 20:25:23 +02:00
JailDesigner 4ea8ab7b6d art(whip): actualitza els 4 GIFs direccionals del fuet 2026-05-07 20:12:57 +02:00
JailDesigner 05465b791e refactor(data): reorganitza data/ i centralitza paths a defaults.hpp 2026-05-07 19:28:28 +02:00
JailDesigner 9949096fba chore(rename): aee_2026 -> aee_arcade 2026-05-07 18:39:02 +02:00
127 changed files with 2642 additions and 755 deletions

No files matched your search

+2 -2
View File
@@ -8,8 +8,8 @@ dist/
*.dylib
*.dll
*.exe
aee_2026
_aee_2026
aee_arcade
_aee_arcade
.vscode/
.idea/
.cache/
+14 -1
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.16)
project(aee_2026 VERSION 0.1.0 LANGUAGES CXX)
project(aee_arcade VERSION 0.1.0 LANGUAGES CXX)
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE Debug CACHE STRING "" FORCE)
@@ -104,18 +104,22 @@ set(APP_SOURCES
source/core/resources/resource_pack.cpp
source/core/resources/palette_loader.cpp
source/utils/easing.cpp
source/utils/log.cpp
source/utils/utils.cpp
source/game/debug_config.cpp
source/game/gameplay_config.cpp
source/game/options.cpp
source/game/options_apply.cpp
source/game/ui/service_menu.cpp
source/game/ui/hud.cpp
source/game/scenes/placeholder_input.cpp
source/game/scenes/start_input.cpp
source/game/scenes/logo_scene.cpp
source/game/scenes/title_scene.cpp
source/game/scenes/intro_scene.cpp
source/game/scenes/game_scene.cpp
source/game/scenes/room_fsm.cpp
source/game/scenes/game_over_fsm.cpp
source/game/entities/player.cpp
source/game/map/map.cpp
source/game/map/tilemap.cpp
@@ -218,8 +222,10 @@ target_link_libraries(${PROJECT_NAME} PRIVATE SDL3::SDL3)
add_executable(pack_resources EXCLUDE_FROM_ALL
tools/pack_resources/pack_resources.cpp
source/core/resources/resource_pack.cpp
source/utils/log.cpp
)
target_include_directories(pack_resources PRIVATE "${CMAKE_SOURCE_DIR}/source")
target_link_libraries(pack_resources PRIVATE SDL3::SDL3)
target_compile_options(pack_resources PRIVATE -Wall -Wextra -Wpedantic)
# --- REGENERACIÓ AUTOMÀTICA DE build/resources.pack ---
@@ -361,9 +367,11 @@ if(BUILD_TESTS)
add_executable(test_entities_smoke
tests/test_entities_smoke.cpp
tests/hole_player_stubs.cpp
tests/audio_cache_stubs.cpp
source/core/resources/resource_helper.cpp
source/core/resources/resource_pack.cpp
source/game/map/map.cpp
source/utils/log.cpp
${ENTITY_SOURCES_BASE}
)
target_include_directories(test_entities_smoke PRIVATE
@@ -389,9 +397,12 @@ if(BUILD_TESTS)
tests/test_whip_attack.cpp
tests/whip_attack_player_stubs.cpp
tests/hole_player_stubs.cpp
tests/audio_cache_stubs.cpp
source/core/resources/resource_helper.cpp
source/core/resources/resource_pack.cpp
source/game/gameplay_config.cpp
source/game/map/map.cpp
source/utils/log.cpp
${ENTITY_SOURCES_BASE}
source/game/entities/concrete/whip_attack.cpp
)
@@ -416,6 +427,7 @@ if(BUILD_TESTS)
source/core/input/key_config.cpp
source/core/resources/resource_helper.cpp
source/core/resources/resource_pack.cpp
source/utils/log.cpp
)
target_include_directories(test_scene_flow PRIVATE
"${CMAKE_SOURCE_DIR}/source"
@@ -455,6 +467,7 @@ if(BUILD_TESTS)
source/core/resources/resource_helper.cpp
source/core/resources/resource_pack.cpp
source/utils/easing.cpp
source/utils/log.cpp
)
target_include_directories(test_game_fsm PRIVATE
"${CMAKE_SOURCE_DIR}/source"
+1 -1
View File
@@ -1,4 +1,4 @@
PROJECT := aee_2026
PROJECT := aee_arcade
BUILDDIR := build
# Detecció de plataforma. En Windows CMake defaulteja a "NMake Makefiles"
Binary file not shown.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Binary file not shown.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Binary file not shown.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,4 +1,4 @@
# AEE 2026 — Tecles d'UI (Global Inputs)
# AEE ARCADE — Tecles d'UI (Global Inputs)
#
# Mapeig id → codi SDL (el que reconeix SDL_GetScancodeFromName). Les tecles
# de moviment del jugador viuen a part (Defaults::KeysGame); ací només hi ha
File renamed without changes.
+115
View File
@@ -0,0 +1,115 @@
# Tema de la UI: índexs de paleta, timings, fonts i flags visuals. Tota
# clau absent cau al valor compilat a Defaults::UI (veure defaults.hpp).
colors:
render_info_text: 58
render_info_shadow: 16
canvas_bg: 72
notification_text: 232
notification_bg: 64
exit_text: 71
exit_bg: 2
menu_bg: 2
menu_border: 232
menu_title: 237
menu_label: 58
menu_value: 232
menu_cursor: 71
menu_disabled: 16
hud_bg: 9
hud_label: 58
hud_value: 187
hud_flash: 237
# Rètol de transició de sala (ROOM_INTRO i ROOM_OUTRO). Independents
# de notification_* perquè la fase i les notificacions són dos canals
# visuals distints: ací pots canviar el color del rètol sense tocar
# els missatges del HUD.
room_text: 160
room_text_outline: 253
timings:
notification_ms: 4000
exit_ms: 4000
fonts:
hud:
name: "8bithud"
gif: "data/fonts/8bithud.gif"
fnt: "data/fonts/8bithud.fnt"
kerning: 1
placeholder:
name: "ari-w9500-condensed-display"
gif: "data/fonts/ari-w9500-condensed-display.gif"
fnt: "data/fonts/ari-w9500-condensed-display.fnt"
kerning: 1
# Font del rètol de transició de sala (ROOM_INTRO i ROOM_OUTRO).
room_text:
name: "CaesarDressing-Regular"
gif: "data/fonts/CaesarDressing-Regular.gif"
fnt: "data/fonts/CaesarDressing-Regular.fnt"
kerning: 1
menu_title:
name: "ari-w9500-display"
gif: "data/fonts/ari-w9500-display.gif"
fnt: "data/fonts/ari-w9500-display.fnt"
kerning: 0
menu_items:
name: "ari-w9500-condensed-display"
gif: "data/fonts/ari-w9500-condensed-display.gif"
fnt: "data/fonts/ari-w9500-condensed-display.fnt"
kerning: 0
# Copyright de la TITLE: pixel-art estricte (Tiny5-Regular size 5,
# caixa 3×6 px) generat amb font-gen. Diferenciada de la `placeholder`
# que pinta el "press start" — així la jerarquia tipogràfica del peu
# queda neta.
copyright:
name: "Tiny5-Regular"
gif: "data/fonts/Tiny5-Regular.gif"
fnt: "data/fonts/Tiny5-Regular.fnt"
kerning: 1
hud:
bg_enabled: false
entry_ms: 800 # durada de l'animació d'entrada dels tres blocs
flash_ms: 250 # durada del flash quan un jugador rep un hit
shake_ms: 300 # durada del shake que acompanya el flash
# Geometria del popup superior (Notifier). En píxels del canvas, llevat de
# slide_ms (durada en ms de l'animació d'entrada i d'eixida; constant
# independentment de la mida de la caixa). El padding vertical és asimètric a
# propòsit: padding_top > padding_bottom puja una mica el text dins la caixa.
# Corbes disponibles: linear, outQuad, inQuad, inOutQuad, outCubic, inCubic.
notifier:
padding_h: 6
padding_top: 4
padding_bottom: 3
line_gap: 1
margin_top: 3
border_px: 1
slide_ms: 300
slide_in_curve: outCubic
slide_out_curve: inCubic
# Pausa graduada de la GameScene: el món no para d'un colp, s'atura amb
# easing i torna a arrancar amb una altra corba. Els timings es poden
# afinar independentment, i les corbes permeten estils diferents entrant
# i sortint (p.ex. frenar suau i arrancar seca). Corbes disponibles:
# linear, outQuad, inQuad, inOutQuad, outCubic, inCubic.
pause:
enter_ms: 400
exit_ms: 250
enter_curve: outCubic
exit_curve: inCubic
# Sons del menú de servei. Els fitxers viuen a data/audio/sounds/. SELECT
# acompanya navegació (amunt/avall) i back; ACCEPT acompanya modificació
# d'opció (esquerra/dreta/retorn) o entrada a un submenú.
sounds:
menu_select: "menu_select.wav"
menu_accept: "menu_accept.wav"
# Disparat quan un jugador prem START a la TITLE (primera pulsació o
# la del segon jugador dins de la finestra de STARTING).
player_start: "player_start.wav"
# Soroll/scratch sincronitzat amb la fase SCAN de LogoScene (scanline
# reveal). Es dispara al constructor de l'escena.
logo_glitch: "glitch.wav"
+3
View File
@@ -58,3 +58,6 @@ hurt:
duration_ms: 600
speed: 60
whippable: true
# So opcional: basename del fitxer dins `data/audio/sounds/`. Es dispara
# un sol cop per fuetada quan la mòmia entra a HURT. Si manca, no sona.
sound: mummy_hurt.wav
File renamed without changes.
File renamed without changes.
@@ -40,10 +40,10 @@ animations:
hurt_east: { frames: [8, 9, 10, 11], frame_ms: 100 }
hurt_west: { frames: [8, 9, 10, 11], frame_ms: 100, flip_h: true }
attack_south: { frames: [20, 21, 22, 23], frame_ms: 50 }
attack_north: { frames: [24, 25, 26, 27], frame_ms: 50 }
attack_east: { frames: [28, 29, 30, 31], frame_ms: 50 }
attack_west: { frames: [28, 29, 30, 31], frame_ms: 50, flip_h: true }
attack_south: { frames: [20, 21, 22, 23], frame_ms: 80 }
attack_north: { frames: [24, 25, 26, 27], frame_ms: 80 }
attack_east: { frames: [28, 29, 30, 31], frame_ms: 80 }
attack_west: { frames: [28, 29, 30, 31], frame_ms: 80, flip_h: true }
recover_south: { frames: [23] }
recover_north: { frames: [27] }
@@ -119,7 +119,7 @@ hurt:
# recuperació en què la hitbox ja no fa dany.
whip:
range_tiles: 3
swing_ms: 200
swing_ms: 320
cooldown_ms: 200
damage: 1
sound: whip.wav
@@ -136,7 +136,7 @@ death:
# poden ferir ni matar el jugador (els forats sí que el maten — la
# caiguda no és dany convencional). Visualment es manifesta amb un
# parpadeig que alterna la paleta normal del sprite amb la paleta
# `palette` (basename de `data/player/<name>.pal`) cada
# `palette` (basename de `data/entities/player/<name>.pal`) cada
# `blink_period_ms` ms. Si `duration_ms` és 0 (o el bloc manca), la
# mecànica queda desactivada i el comportament és l'antic.
immunity:
File renamed without changes.
@@ -40,10 +40,10 @@ animations:
hurt_east: { frames: [8, 9, 10, 11], frame_ms: 100 }
hurt_west: { frames: [8, 9, 10, 11], frame_ms: 100, flip_h: true }
attack_south: { frames: [20, 21, 22, 23], frame_ms: 50 }
attack_north: { frames: [24, 25, 26, 27], frame_ms: 50 }
attack_east: { frames: [28, 29, 30, 31], frame_ms: 50 }
attack_west: { frames: [28, 29, 30, 31], frame_ms: 50, flip_h: true }
attack_south: { frames: [20, 21, 22, 23], frame_ms: 80 }
attack_north: { frames: [24, 25, 26, 27], frame_ms: 80 }
attack_east: { frames: [28, 29, 30, 31], frame_ms: 80 }
attack_west: { frames: [28, 29, 30, 31], frame_ms: 80, flip_h: true }
recover_south: { frames: [23] }
recover_north: { frames: [27] }
@@ -119,7 +119,7 @@ hurt:
# recuperació en què la hitbox ja no fa dany.
whip:
range_tiles: 3
swing_ms: 200
swing_ms: 320
cooldown_ms: 200
damage: 1
sound: whip.wav
@@ -136,7 +136,7 @@ death:
# poden ferir ni matar el jugador (els forats sí que el maten — la
# caiguda no és dany convencional). Visualment es manifesta amb un
# parpadeig que alterna la paleta normal del sprite amb la paleta
# `palette` (basename de `data/player/<name>.pal`) cada
# `palette` (basename de `data/entities/player/<name>.pal`) cada
# `blink_period_ms` ms. Si `duration_ms` és 0 (o el bloc manca), la
# mecànica queda desactivada i el comportament és l'antic.
immunity:
+2 -2
View File
@@ -25,7 +25,7 @@ sprites:
# Animació compartida entre les 4 direccions: la seqüència de frames i les
# durades són idèntiques; només canvia quina surface/mida es clipa.
# - `frames` : índex de tile dins l'spritesheet (graella deduïda).
# - `frame_ms` : durada uniforme per frame; 50 ms × 4 ≈ 200 ms swing.
# - `frame_ms` : durada uniforme per frame; 80 ms × 4 = 320 ms swing.
# - `loop: false` : el látigo viu una animació i mor (`markDead`).
# - `active_frames` : valors dins de `frames` durant els quals la
# hitbox aplica dany. Wind-up (0) i recovery (3) no
@@ -33,6 +33,6 @@ sprites:
animations:
swing:
frames: [0, 1, 2, 3]
frame_ms: 50
frame_ms: 80
loop: false
active_frames: [1, 2]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 313 B

After

Width:  |  Height:  |  Size: 311 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 378 B

After

Width:  |  Height:  |  Size: 363 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 369 B

After

Width:  |  Height:  |  Size: 348 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 313 B

After

Width:  |  Height:  |  Size: 327 B

+19 -1
View File
@@ -1,4 +1,4 @@
# AEE 2026 — Català
# AEE ARCADE — Català
# lang: ca
notification:
@@ -38,6 +38,13 @@ hud:
treasures: "Tres"
pyramid: "Piramide"
room: "Sala"
# Prompts mostrats al bloc d'un slot inactiu (en lloc dels zeros)
# quan un jugador encara no ha pulsat START o ha esgotat vides.
# Dues línies per fer-los caber al bloc lateral (~106 px).
join_prompt_can_join_l1: "POLSA START"
join_prompt_can_join_l2: "PER JUGAR"
join_prompt_wait_l1: "ESPERA"
join_prompt_wait_l2: "PER JUGAR"
# Textos de l'escena TITLE.
title:
@@ -53,6 +60,12 @@ game:
room_intro_room: "Sala {r}"
room_complete_line1: "Fase"
room_complete_line2: "Completada"
# Rètol del game over (sub-FSM de la GameScene). El countdown es
# pinta com a segona línia (un enter) durant la fase 1 de
# CONTINUE_PROMPT — només cal traduir el rètol superior.
continue_prompt: "Continuar?"
game_over_line1: "Fi del"
game_over_line2: "Joc"
menu:
titles:
@@ -62,6 +75,7 @@ menu:
controls: "Controls"
keyboard: "Teclat"
gamepad: "Comandament"
tweaks: "Jugabilitat"
system: "Sistema"
labels:
player_1: "Jugador 1"
@@ -73,6 +87,7 @@ menu:
controls_submenu: "Controls"
keyboard_submenu: "Teclat"
gamepad_submenu: "Comandament"
tweaks_submenu: "Jugabilitat"
system_submenu: "Sistema"
move_up: "Amunt"
move_down: "Avall"
@@ -101,6 +116,9 @@ menu:
music_volume: "Volum música"
sounds: "Sons"
sounds_volume: "Volum sons"
whip_pierces_walls: "Látec travessa sarcs"
friendly_fire: "Foc amic"
players_collide: "Col·lisió entre jugadors"
restart: "Reiniciar"
exit_game: "Eixir del joc"
values:
+19 -1
View File
@@ -1,4 +1,4 @@
# AEE 2026 — English
# AEE ARCADE — English
# lang: en
notification:
@@ -38,6 +38,13 @@ hud:
treasures: "Tre"
pyramid: "Pyramid"
room: "Room"
# Prompts shown in an inactive slot's block (instead of zeros) while
# a player hasn't pressed START yet or has run out of lives. Two
# short lines so they fit in the side block (~106 px wide).
join_prompt_can_join_l1: "PRESS START"
join_prompt_can_join_l2: "TO PLAY"
join_prompt_wait_l1: "PLEASE"
join_prompt_wait_l2: "WAIT"
# Texts of the TITLE scene.
title:
@@ -52,6 +59,12 @@ game:
room_intro_room: "Room {r}"
room_complete_line1: "Stage"
room_complete_line2: "Complete"
# Game over labels (sub-FSM in GameScene). The countdown is drawn as
# a numeric second line during CONTINUE_PROMPT phase 1, so only the
# top label needs translation.
continue_prompt: "Continue?"
game_over_line1: "Game"
game_over_line2: "Over"
menu:
titles:
@@ -61,6 +74,7 @@ menu:
controls: "Controls"
keyboard: "Keyboard"
gamepad: "Gamepad"
tweaks: "Gameplay"
system: "System"
labels:
player_1: "Player 1"
@@ -72,6 +86,7 @@ menu:
controls_submenu: "Controls"
keyboard_submenu: "Keyboard"
gamepad_submenu: "Gamepad"
tweaks_submenu: "Gameplay"
system_submenu: "System"
move_up: "Up"
move_down: "Down"
@@ -100,6 +115,9 @@ menu:
music_volume: "Music volume"
sounds: "Sounds"
sounds_volume: "Sound volume"
whip_pierces_walls: "Whip pierces sarcs"
friendly_fire: "Friendly fire"
players_collide: "Player collision"
restart: "Restart"
exit_game: "Exit game"
values:
-48
View File
@@ -1,48 +0,0 @@
# Configuració de la GameScene: timings i corbes de la FSM de sala
# (ROOM_INTRO i ROOM_OUTRO). Tots els valors són ms i tota la geometria
# i font del text es reutilitzen del tema UI (data/ui.yaml > pause +
# notification). Si manca el fitxer o alguna clau, GameScene cau als
# fallbacks compilats; veure `readSceneConfig` a game_scene.cpp.
room_intro:
fade_in_ms: 500 # negre opac → món visible
text_delay_ms: 500 # espera amb el món net abans de sortir el text
text_enter_ms: 400 # text esquerra → centre
text_hold_ms: 2000 # text quiet al centre
text_exit_ms: 400 # text centre → fora per l'esquerra
line_gap_px: -4 # px extra entre les dues línies (piràmide / sala)
# Delays d'spawn aeri **mesurats des de la fi de la música d'INTRO**
# (callback `Audio::setOnMusicEnded`). 0 = cau al moment exacte que
# `room_intro.ogg` acaba; valors > 0 escalonen els jugadors. En mode
# single-player s'usa només `player_spawn_delay_ms`.
player_spawn_delay_ms: 0
player2_spawn_delay_ms: 300
# Preset de reverb (catàleg a `data/sounds.yaml`) aplicat al primer
# LAND de cada jugador durant la cinemàtica d'entrada. La sala dona
# "més detall" sonor a l'INTRO; els salts del joc i els respawns
# post-mort cauen al so sec. Buit o absent = sense reverb.
land_reverb: stone_hall
room_outro:
text_enter_ms: 400
text_hold_ms: 2000
text_exit_ms: 400
fade_out_ms: 500 # món visible → negre opac
line_gap_px: -4 # px extra entre les dues línies (fase / completada)
black_hold_ms: 500 # pausa en negre abans d'entrar a la pròxima sala
text:
# Noms d'easing tal com els resol UiConfig::curveByName (vegeu
# `data/ui.yaml`). El text entra suau i surt accelerant.
enter_curve: outCubic
exit_curve: inCubic
# Panel translúcid pintat sota el rètol de transició (INTRO i OUTRO).
# Va centrat verticalment al centre del playfield i ocupa tota
# l'amplada del canvas. Creix d'altura amb la mateixa enter_curve del
# text i decreix amb l'exit_curve. `color` són tres canals RGB (0..255);
# `alpha` és la transparència del blend (0 = invisible, 255 = opac).
text_panel:
color: [36, 182, 170]
alpha: 128
height_px: 32
+93
View File
@@ -0,0 +1,93 @@
# Configuració de la GameScene: timings i corbes de la FSM de sala
# (ROOM_INTRO i ROOM_OUTRO). Tots els valors són ms i tota la geometria
# i font del text es reutilitzen del tema UI (data/config/ui.yaml > pause +
# notification). Si manca el fitxer o alguna clau, GameScene cau als
# fallbacks compilats; veure `readSceneConfig` a game_scene.cpp.
room_intro:
fade_in_ms: 500 # negre opac → món visible
text_delay_ms: 500 # espera amb el món net abans de sortir el text
text_enter_ms: 400 # text esquerra → centre
text_hold_ms: 2000 # text quiet al centre
text_exit_ms: 400 # text centre → fora per l'esquerra
line_gap_px: -4 # px extra entre les dues línies (piràmide / sala)
# Delays d'spawn aeri **mesurats des de la fi de la música d'INTRO**
# (callback `Audio::setOnMusicEnded`). 0 = cau al moment exacte que
# `room_intro.ogg` acaba; valors > 0 escalonen els jugadors. En mode
# single-player s'usa només `player_spawn_delay_ms`.
player_spawn_delay_ms: 0
player2_spawn_delay_ms: 300
# Preset de reverb (catàleg a `data/config/sounds.yaml`) aplicat al primer
# LAND de cada jugador durant la cinemàtica d'entrada. La sala dona
# "més detall" sonor a l'INTRO; els salts del joc i els respawns
# post-mort cauen al so sec. Buit o absent = sense reverb.
land_reverb: stone_hall
room_outro:
text_enter_ms: 400
text_hold_ms: 2000
text_exit_ms: 400
fade_out_ms: 500 # món visible → negre opac
line_gap_px: -4 # px extra entre les dues línies (fase / completada)
black_hold_ms: 500 # pausa en negre abans d'entrar a la pròxima sala
text:
# Noms d'easing tal com els resol UiConfig::curveByName (vegeu
# `data/config/ui.yaml`). El text entra suau i surt accelerant.
enter_curve: outCubic
exit_curve: inCubic
# Panel translúcid pintat sota el rètol de transició (INTRO i OUTRO).
# Va centrat verticalment al centre del playfield i ocupa tota
# l'amplada del canvas. Creix d'altura amb la mateixa enter_curve del
# text i decreix amb l'exit_curve. `color` són tres canals RGB (0..255);
# `alpha` és la transparència del blend (0 = invisible, 255 = opac).
text_panel:
color: [36, 182, 170]
alpha: 128
height_px: 32
# Sub-FSM de game over: dispara CONTINUE? quan tots els jugadors
# queden fora de partida durant PLAYING, fa countdown, i si ningú
# pulsa START acaba en GAME OVER + fade-to-black + transició a LOGO.
# Tots els valors són ms (excepte `continue_count_steps`, que és el
# nombre de tics visibles del countdown). Si manca el fitxer o alguna
# clau, GameScene cau als fallbacks compilats (vegeu `GameOverFsm::Config`).
game_over:
# CONTINUE? — animació d'entrada i sortida idèntica al room text.
continue_text_enter_ms: 400
# Hold del countdown: 10 s = (continue_count_start + 1) graons d'1 s.
# La sèrie visible és [continue_count_start, ..., 1, 0]; el "0" es
# manté l'últim segon abans del salt a GAME OVER.
continue_text_hold_ms: 10000
continue_text_exit_ms: 400
continue_count_start: 9
# GAME OVER — apareix sense enter anim (panel ja amunt del CONTINUE?),
# es manté un temps i marxa amb la mateixa exit anim del room text.
game_over_text_hold_ms: 1500
game_over_text_exit_ms: 400
game_over_wait_ms: 400
# Fade final cap a LOGO. `black_hold_ms` és la pausa en negre opac
# abans que el GameScene demani la transició d'escena.
fade_out_ms: 500
black_hold_ms: 500
# Velocitat (frequency ratio) de la música mentre dura el countdown
# de CONTINUE_PROMPT. 1.0 = sense canvi, 1.5 = un 50% més ràpid (i
# amb el to més agut: efecte arcade clàssic). Es restableix a 1.0 al
# sortir del prompt — tant si el jugador reviu com si entra GAME OVER.
continue_music_speed: 1.05
# Pista de música que sona durant l'estat GAME_OVER (sense loop).
# La fase de hold del text es sincronitza amb la durada real de la
# pista, així que el rètol "Fi del Joc" es manté visible mentre dura
# la música; després ve l'exit anim, el wait i el fade a LOGO.
game_over_music_track: "game_over.ogg"
# So que sona cada vegada que apareix un nou número del countdown
# (al primer cop que es mostra `continue_count_start`, a cada canvi,
# i quan apareix el 0 — però NO al saltar a GAME OVER). Buit = silenci.
countdown_sound_key: "countdown.wav"
# Llindar a partir del qual qualsevol botó de salt o atac de
# qualsevol jugador avança al següent número del countdown.
# 7 → skip habilitat mentre el número visible és 7..1 (no a 0).
# -1 = mai. Útil per accelerar la decisió un cop la urgència ja
# és evident al jugador.
skip_below_or_equal: 7
+24
View File
@@ -0,0 +1,24 @@
# Configuració de la LogoScene (logotip de l'editora). El revelat del
# logo a la fase SCAN imita un escaneig CRT: una banda lluminosa baixa
# de dalt a baix; per damunt es veu la imatge ja "impressa", per sota
# encara és negre. Al voltant de la banda hi ha glitch (offset
# horitzontal de files amb falloff per distància) i ocasionalment
# apareixen bursts VHS (franges desplaçades) damunt la part ja
# revelada. Quan la banda toca el peu del logo, un flash blanc tanca
# l'efecte i s'entra a MIDDLE (logo estable).
logo:
scan:
duration_ms: 1500 # durada total del scan top→bottom
band_height_px: 4 # alçada (px) de la banda lluminosa
band_alpha: 200 # opacitat (0..255) de la capa blanca de la banda
band_pulse_hz: 20.0 # freqüència del pulse de brillantor (Hz)
band_pulse_depth: 0.45 # profunditat del pulse (0..1; 0 = sense pulse)
# Glitch d'impacte (files al voltant de la banda)
glitch_amplitude_px: 6 # offset horitzontal màxim a la banda
glitch_band_radius_px: 12 # files damunt/sota la banda amb glitch (falloff lineal)
# Bursts VHS sobre la part ja revelada
vhs_burst_per_sec: 3 # bursts/segon mitjà
vhs_burst_duration_ms: 80 # durada de cada burst
vhs_burst_height_px: 8 # alçada de la franja afectada
vhs_burst_amplitude_px: 8 # offset horitzontal del burst
flash_ms: 100 # flash blanc final quan acaba el scan
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+77
View File
@@ -0,0 +1,77 @@
# Configuració de la TitleScene.
# `bg_color` i `text_color` són índexs sobre la paleta "ui" (uzebox.pal):
# 1 = (36,0,0) gairebé negre amb un mínim de càlid; 31 = (255,109,0)
# taronja pur. `duration_ms` és el timeout abans de saltar a la
# següent escena (skipable amb tecla no reservada). Els dos
# `blink_*_ms` defineixen el cicle del prompt "press start": el text
# es veu durant `visible_ms` i s'amaga durant `hidden_ms` repetint-se.
title:
duration_ms: 30000
bg_color: 1
text_color: 31
# Color del text del © de copyright (peu de la TITLE). Off-white càlid:
# índex 183 = (255,218,170), tipus crema/peach, ~90% blanc amb tint càlid.
copyright_color: 183
# Color de l'outline del prompt "PRESS START". Marró fosc càlid (índex
# 19 = 109,72,0), coherent amb la paleta del logo. Es manté separat de
# `copyright_color` perquè l'outline i el text del © tenen rols visuals
# diferents (contrast vs. peu informatiu).
press_start_outline_color: 19
# Posicions Y dels tres elements verticals de la TITLE, en percentatge
# sobre l'alçada del canvas (240 px). El logo s'ancora pel seu top
# (la imatge cau cap avall des d'aquest Y); el prompt i el copyright
# es centren verticalment sobre la línia.
logo_y_percent: 15
prompt_y_percent: 68
copyright_y_percent: 92
# Separació vertical en píxels entre el minilogo i el text del © al peu.
mini_logo_gap_px: 2
blink_visible_ms: 800
blink_hidden_ms: 200
# Fase STARTING (entre la primera pulsació de START i el salt a la
# següent escena). `starting_ms` és la durada fixa; durant aquesta
# fase el parpadeig accelera per `starting_blink_speedup` (2.0 = doble
# de freqüència). Si l'altre jugador prem START dins d'aquesta
# finestra, el comptador es reseteja i s'arrenca amb BOTH.
starting_ms: 3000
# Durant STARTING el prompt commuta a un cicle simètric 50/50
# (visible/ocult) amb aquest període total. 200 ms = 5 Hz, prou
# ràpid per llegir-se com a "pulsació confirmada".
starting_blink_period_ms: 200
# Fade-out estil "mosaic dissolve" durant l'última part de STARTING.
# `fade_out_ms` ha de ser <= starting_ms (els últims X ms del STARTING
# es fan amb el dissolve actiu fins a quedar negre). `fade_out_max_block`
# és la mida màxima de bloc en píxels al final del fade.
fade_out_ms: 800
fade_out_max_block: 32
# Fade-out de `title.ogg` en entrar a STARTING. Independent del visual:
# la música pot acabar abans o després del mosaic dissolve.
music_fade_out_ms: 3000
# Fade-to-black per a la sortida d'attract (timeout cap a LOGO). NO és
# mosaic — capa negra lineal damunt del canvas. La música no es toca:
# `title.ogg` continua sonant cap a LOGO, que ja veurà què en fa.
attract_fade_to_black_ms: 800
# Seqüència d'aparició dels elements de la TITLE en fase IDLE. Tots
# els temps són absoluts des de l'entrada a l'escena. `fade_in_ms` és
# la capa negra inicial que s'esvaeix cap al fons; els altres marquen
# quan apareix cada element. Si l'usuari prem START en qualsevol
# moment, la seqüència es completa instantàniament.
intro:
fade_in_ms: 500 # fade-in negre inicial sobre el fons
game_logo_at_ms: 1500 # visibilitat binària del game_logo
mini_logo_at_ms: 2000 # inici del slide-in del mini-logo
mini_logo_slide_ms: 500 # durada del slide
copyright_at_ms: 2200 # inici del slide-in del copyright
copyright_slide_ms: 500 # durada del slide
slide_curve: outCubic # corba compartida per mini_logo i copyright
press_start_at_ms: 3000 # quan apareix PRESS START amb parpadeig
# Distorsió sinusoidal del fons (efecte de calor del desert).
# `amplitude_px`: desplaçament màxim de cada fila en píxels.
# `wavelength_px`: distància vertical entre dos pics de l'ona.
# `speed_hz`: velocitat amb què avança la fase (cicles per segon).
# Si `enabled` és false, el fons es pinta sense distorsió.
heat_haze:
enabled: true
amplitude_px: 1.5
wavelength_px: 32.0
speed_hz: 0.6
File renamed without changes.
-217
View File
@@ -1,217 +0,0 @@
# Tema de la UI: índexs de paleta, timings, fonts i flags visuals. Tota
# clau absent cau al valor compilat a Defaults::UI (veure defaults.hpp).
colors:
render_info_text: 58
render_info_shadow: 16
canvas_bg: 72
notification_text: 232
notification_bg: 64
exit_text: 71
exit_bg: 2
menu_bg: 2
menu_border: 232
menu_title: 237
menu_label: 58
menu_value: 232
menu_cursor: 71
menu_disabled: 16
hud_bg: 9
hud_label: 58
hud_value: 187
hud_flash: 237
# Rètol de transició de sala (ROOM_INTRO i ROOM_OUTRO). Independents
# de notification_* perquè la fase i les notificacions són dos canals
# visuals distints: ací pots canviar el color del rètol sense tocar
# els missatges del HUD.
room_text: 160
room_text_outline: 253
timings:
notification_ms: 4000
exit_ms: 4000
fonts:
hud:
name: "8bithud"
gif: "data/fonts/8bithud.gif"
fnt: "data/fonts/8bithud.fnt"
kerning: 1
placeholder:
name: "ari-w9500-condensed-display"
gif: "data/fonts/ari-w9500-condensed-display.gif"
fnt: "data/fonts/ari-w9500-condensed-display.fnt"
kerning: 1
# Font del rètol de transició de sala (ROOM_INTRO i ROOM_OUTRO).
room_text:
name: "CaesarDressing-Regular"
gif: "data/fonts/CaesarDressing-Regular.gif"
fnt: "data/fonts/CaesarDressing-Regular.fnt"
kerning: 1
menu_title:
name: "ari-w9500-display"
gif: "data/fonts/ari-w9500-display.gif"
fnt: "data/fonts/ari-w9500-display.fnt"
kerning: 0
menu_items:
name: "ari-w9500-condensed-display"
gif: "data/fonts/ari-w9500-condensed-display.gif"
fnt: "data/fonts/ari-w9500-condensed-display.fnt"
kerning: 0
# Copyright de la TITLE: pixel-art estricte (Tiny5-Regular size 5,
# caixa 3×6 px) generat amb font-gen. Diferenciada de la `placeholder`
# que pinta el "press start" — així la jerarquia tipogràfica del peu
# queda neta.
copyright:
name: "Tiny5-Regular"
gif: "data/fonts/Tiny5-Regular.gif"
fnt: "data/fonts/Tiny5-Regular.fnt"
kerning: 1
hud:
bg_enabled: false
entry_ms: 800 # durada de l'animació d'entrada dels tres blocs
flash_ms: 250 # durada del flash quan un jugador rep un hit
shake_ms: 300 # durada del shake que acompanya el flash
# Geometria del popup superior (Notifier). En píxels del canvas, llevat de
# slide_ms (durada en ms de l'animació d'entrada i d'eixida; constant
# independentment de la mida de la caixa). El padding vertical és asimètric a
# propòsit: padding_top > padding_bottom puja una mica el text dins la caixa.
# Corbes disponibles: linear, outQuad, inQuad, inOutQuad, outCubic, inCubic.
notifier:
padding_h: 6
padding_top: 4
padding_bottom: 3
line_gap: 1
margin_top: 3
border_px: 1
slide_ms: 300
slide_in_curve: outCubic
slide_out_curve: inCubic
# Pausa graduada de la GameScene: el món no para d'un colp, s'atura amb
# easing i torna a arrancar amb una altra corba. Els timings es poden
# afinar independentment, i les corbes permeten estils diferents entrant
# i sortint (p.ex. frenar suau i arrancar seca). Corbes disponibles:
# linear, outQuad, inQuad, inOutQuad, outCubic, inCubic.
pause:
enter_ms: 400
exit_ms: 250
enter_curve: outCubic
exit_curve: inCubic
# Sons del menú de servei. Els fitxers viuen a data/sounds/. SELECT
# acompanya navegació (amunt/avall) i back; ACCEPT acompanya modificació
# d'opció (esquerra/dreta/retorn) o entrada a un submenú.
sounds:
menu_select: "menu_select.wav"
menu_accept: "menu_accept.wav"
# Disparat quan un jugador prem START a la TITLE (primera pulsació o
# la del segon jugador dins de la finestra de STARTING).
player_start: "player_start.wav"
# Soroll/scratch sincronitzat amb la fase SCAN de LogoScene (scanline
# reveal). Es dispara al constructor de l'escena.
logo_glitch: "glitch.wav"
# Escena TITLE (placeholder visual mentre no hi haja artwork).
# `bg_color` i `text_color` són índexs sobre la paleta "ui" (uzebox.pal):
# 1 = (36,0,0) gairebé negre amb un mínim de càlid; 31 = (255,109,0)
# taronja pur. `duration_ms` és el timeout abans de saltar a la
# següent escena (skipable amb tecla no reservada). Els dos
# `blink_*_ms` defineixen el cicle del prompt "press start": el text
# es veu durant `visible_ms` i s'amaga durant `hidden_ms` repetint-se.
title:
duration_ms: 30000
bg_color: 1
text_color: 31
# Color del text del © de copyright (peu de la TITLE). Off-white càlid:
# índex 183 = (255,218,170), tipus crema/peach, ~90% blanc amb tint càlid.
copyright_color: 183
# Color de l'outline del prompt "PRESS START". Marró fosc càlid (índex
# 19 = 109,72,0), coherent amb la paleta del logo. Es manté separat de
# `copyright_color` perquè l'outline i el text del © tenen rols visuals
# diferents (contrast vs. peu informatiu).
press_start_outline_color: 19
# Posicions Y dels tres elements verticals de la TITLE, en percentatge
# sobre l'alçada del canvas (240 px). El logo s'ancora pel seu top
# (la imatge cau cap avall des d'aquest Y); el prompt i el copyright
# es centren verticalment sobre la línia.
logo_y_percent: 15
prompt_y_percent: 68
copyright_y_percent: 92
# Separació vertical en píxels entre el minilogo i el text del © al peu.
mini_logo_gap_px: 2
blink_visible_ms: 800
blink_hidden_ms: 200
# Fase STARTING (entre la primera pulsació de START i el salt a la
# següent escena). `starting_ms` és la durada fixa; durant aquesta
# fase el parpadeig accelera per `starting_blink_speedup` (2.0 = doble
# de freqüència). Si l'altre jugador prem START dins d'aquesta
# finestra, el comptador es reseteja i s'arrenca amb BOTH.
starting_ms: 3000
# Durant STARTING el prompt commuta a un cicle simètric 50/50
# (visible/ocult) amb aquest període total. 200 ms = 5 Hz, prou
# ràpid per llegir-se com a "pulsació confirmada".
starting_blink_period_ms: 200
# Fade-out estil "mosaic dissolve" durant l'última part de STARTING.
# `fade_out_ms` ha de ser <= starting_ms (els últims X ms del STARTING
# es fan amb el dissolve actiu fins a quedar negre). `fade_out_max_block`
# és la mida màxima de bloc en píxels al final del fade.
fade_out_ms: 800
fade_out_max_block: 32
# Fade-out de `title.ogg` en entrar a STARTING. Independent del visual:
# la música pot acabar abans o després del mosaic dissolve.
music_fade_out_ms: 3000
# Fade-to-black per a la sortida d'attract (timeout cap a LOGO). NO és
# mosaic — capa negra lineal damunt del canvas. La música no es toca:
# `title.ogg` continua sonant cap a LOGO, que ja veurà què en fa.
attract_fade_to_black_ms: 800
# Seqüència d'aparició dels elements de la TITLE en fase IDLE. Tots
# els temps són absoluts des de l'entrada a l'escena. `fade_in_ms` és
# la capa negra inicial que s'esvaeix cap al fons; els altres marquen
# quan apareix cada element. Si l'usuari prem START en qualsevol
# moment, la seqüència es completa instantàniament.
intro:
fade_in_ms: 500 # fade-in negre inicial sobre el fons
game_logo_at_ms: 1500 # visibilitat binària del game_logo
mini_logo_at_ms: 2000 # inici del slide-in del mini-logo
mini_logo_slide_ms: 500 # durada del slide
copyright_at_ms: 2200 # inici del slide-in del copyright
copyright_slide_ms: 500 # durada del slide
slide_curve: outCubic # corba compartida per mini_logo i copyright
press_start_at_ms: 3000 # quan apareix PRESS START amb parpadeig
# Distorsió sinusoidal del fons (efecte de calor del desert).
# `amplitude_px`: desplaçament màxim de cada fila en píxels.
# `wavelength_px`: distància vertical entre dos pics de l'ona.
# `speed_hz`: velocitat amb què avança la fase (cicles per segon).
# Si `enabled` és false, el fons es pinta sense distorsió.
heat_haze:
enabled: true
amplitude_px: 1.5
wavelength_px: 32.0
speed_hz: 0.6
# Escena LOGO (logotip de l'editora). El revelat del logo a la fase SCAN
# imita un escaneig CRT: una banda lluminosa baixa de dalt a baix; per
# damunt es veu la imatge ja "impressa", per sota encara és negre. Al
# voltant de la banda hi ha glitch (offset horitzontal de files amb
# falloff per distància) i ocasionalment apareixen bursts VHS (franges
# desplaçades) damunt la part ja revelada. Quan la banda toca el peu del
# logo, un flash blanc tanca l'efecte i s'entra a MIDDLE (logo estable).
logo:
scan:
duration_ms: 1500 # durada total del scan top→bottom
band_height_px: 4 # alçada (px) de la banda lluminosa
band_alpha: 200 # opacitat (0..255) de la capa blanca de la banda
band_pulse_hz: 20.0 # freqüència del pulse de brillantor (Hz)
band_pulse_depth: 0.45 # profunditat del pulse (0..1; 0 = sense pulse)
# Glitch d'impacte (files al voltant de la banda)
glitch_amplitude_px: 6 # offset horitzontal màxim a la banda
glitch_band_radius_px: 12 # files damunt/sota la banda amb glitch (falloff lineal)
# Bursts VHS sobre la part ja revelada
vhs_burst_per_sec: 3 # bursts/segon mitjà
vhs_burst_duration_ms: 80 # durada de cada burst
vhs_burst_height_px: 8 # alçada de la franja afectada
vhs_burst_amplitude_px: 8 # offset horitzontal del burst
flash_ms: 100 # flash blanc final quan acaba el scan
+2 -2
View File
@@ -22,8 +22,8 @@ real (logo animat, menú de títol, cutscene).
Fitxer personal de desenvolupament, **fora del repo**. Viu a la mateixa carpeta
de sistema que `config.yaml`:
- Linux (XDG): `$XDG_CONFIG_HOME/jailgames/aee_2026/debug.yaml`
- Per defecte: `~/.config/jailgames/aee_2026/debug.yaml`
- Linux (XDG): `$XDG_CONFIG_HOME/jailgames/aee_arcade/debug.yaml`
- Per defecte: `~/.config/jailgames/aee_arcade/debug.yaml`
### Comportament segons build
+4 -4
View File
@@ -1,6 +1,6 @@
# Sistema d'entitats
Registre del disseny, els treballs fets i els pendents per al sistema d'entitats (sarcòfags, porta, momia, antorxa, trampes, roca) d'aee_2026. Material de referència per a decisions futures — no és documentació d'API.
Registre del disseny, els treballs fets i els pendents per al sistema d'entitats (sarcòfags, porta, momia, antorxa, trampes, roca) d'aee_arcade. Material de referència per a decisions futures — no és documentació d'API.
Estats possibles: `DONE`, `WIP`, `DEFERRED`, `WONTFIX`.
@@ -24,7 +24,7 @@ Estats possibles: `DONE`, `WIP`, `DEFERRED`, `WONTFIX`.
## 1. Context i objectiu inicial
aee_2026 és un clon tipus Oh Mummy! amb estètica Amidar. Els sarcòfags eren fins ara rectangles purament geomètrics calculats a [Map::getSarcRect](../source/game/map/map.cpp#L67); no hi havia entitats de joc (momies, antorxes, trampes, portes).
aee_arcade és un clon tipus Oh Mummy! amb estètica Amidar. Els sarcòfags eren fins ara rectangles purament geomètrics calculats a [Map::getSarcRect](../source/game/map/map.cpp#L67); no hi havia entitats de joc (momies, antorxes, trampes, portes).
**Objectiu inicial** (prompt de partida):
@@ -158,7 +158,7 @@ Al CMakeLists: `ENTITY_SOURCES` conté els fitxers core; `APP_SOURCES` afegeix `
- `make build`: OK, 0 warnings nous.
- `./build/test_entities_smoke`: **9/9 OK**.
- `ctest --output-on-failure`: 1/1 suite passed.
- `timeout 5 xvfb-run ./build/aee_2026`: 5s sense crash, stdout inclou:
- `timeout 5 xvfb-run ./build/aee_arcade`: 5s sense crash, stdout inclou:
```
Surface : torch.gif ... LOADED
Surface : sarcophagus.gif ... LOADED (×2)
@@ -323,7 +323,7 @@ Dos events encadenats perquè el *pintat* no és la *presència*: es filtra per
- `make debug` net, sense warnings ni de clang-tidy ni de cppcheck als fitxers nous.
- `./build/test_entities_smoke`: **ALL OK** (14/14). T9 adaptat (publica `tile_painted` en lloc d'`on_tile_entered`); la resta de tests del sistema d'entitats passen sense tocar.
- `timeout 5 xvfb-run ./build/aee_2026`: arrenca, log `PaintLayer: 21x15 tiles (336x240 px), tileset data/tilesets/tomb.gif` després de `EntityManager: 27 entitats carregades`, surt amb `Bye!` sense crash.
- `timeout 5 xvfb-run ./build/aee_arcade`: arrenca, log `PaintLayer: 21x15 tiles (336x240 px), tileset data/tilesets/tomb.gif` després de `EntityManager: 27 entitats carregades`, surt amb `Bye!` sense crash.
### Pendent derivat
+4 -4
View File
@@ -1,7 +1,7 @@
# Pla d'implementació — Menú de servei
Pla per a portar el menú de servei de `../aee` (`source/core/rendering/menu.cpp`)
a `aee_2026`, adaptat a l'arquitectura actual (Scenes + Director instanciat +
a `aee_arcade`, adaptat a l'arquitectura actual (Scenes + Director instanciat +
`Options` amb setters persistents + `Screen` ARGB + `Text` bitmap). Document de
referència per a implementar-ho en una sessió futura; no s'ha escrit cap codi
encara.
@@ -33,7 +33,7 @@ Tot el que segueix és la base; la còpia mental serveix per a no reinventar res
## 2. Diferències d'arquitectura rellevants
| Punt | `../aee` | `aee_2026` |
| Punt | `../aee` | `aee_arcade` |
| ----------------- | ---------------------------------------------------- | -------------------------------------------------------------------------- |
| Overlay | `Overlay::render()` dibuixa Menú + Notifier al buffer | No hi ha Overlay. Cada Scene crida `Notifier::render()` i `RenderInfo::render()` |
| Director | Singleton `Director::get()` amb `requestQuit/requestRestart` | Classe instanciada; sortida via `SDL_EVENT_QUIT` a `EventBuffer` |
@@ -223,7 +223,7 @@ es necessita.
### Limitació: pàgina Controls diferida
El menú de `../aee` té una pàgina Controls (`buildControls`) que permet
remappar `up/down/left/right` i la tecla de menú. A `aee_2026`:
remappar `up/down/left/right` i la tecla de menú. A `aee_arcade`:
- Les tecles de joc són `Defaults::KeysGame::{UP,DOWN,LEFT,RIGHT}` — constants
`constexpr`, no mutables.
@@ -273,7 +273,7 @@ y, w, h, argb, alpha)` que opere sobre `canvas_.data()`. El canvas és
fons del menú pot ser `alpha=220` i es veu el joc apagat per darrere.
Mida fixa recomanada per al MVP: `BOX_W = 220`, altura dinàmica (igual que
`../aee`). Canvas lògic d'`aee_2026` és `GameCanvas::WIDTH`/`HEIGHT` —
`../aee`). Canvas lògic d'`aee_arcade` és `GameCanvas::WIDTH`/`HEIGHT` —
confirmar mida real (mateixos 320×200?) a `utils/defines.hpp` abans de
hardcoded.
+3 -3
View File
@@ -7,17 +7,17 @@
<key>CFBundleDisplayName</key>
<string>Aventures en Egipte ARCADE</string>
<key>CFBundleExecutable</key>
<string>aee_2026</string>
<string>aee_arcade</string>
<key>CFBundleIconFile</key>
<string>icon</string>
<key>CFBundleIconName</key>
<string>icon</string>
<key>CFBundleIdentifier</key>
<string>org.jailgames.aee_2026</string>
<string>org.jailgames.aee_arcade</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>aee_2026</string>
<string>aee_arcade</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
@@ -1,2 +1,2 @@
// aee_2026.rc
// aee_arcade.rc
IDI_ICON1 ICON "release/icons/icon.ico"
+7
View File
@@ -113,6 +113,13 @@ void Audio::stopMusic() {
}
}
// NOLINTNEXTLINE(readability-make-member-function-const)
void Audio::setMusicSpeed(float ratio) {
if (music_enabled_) {
engine_->setMusicSpeed(ratio);
}
}
// Reprodueix un so per nom
// NOLINTNEXTLINE(readability-make-member-function-const) — muta estat via engine_; unique_ptr::operator-> no propaga const
void Audio::playSound(const std::string& name, Group group) {
+6 -1
View File
@@ -76,11 +76,16 @@ class Audio {
void stopMusic(); // Aturar completament la música
void fadeOutMusic(int milliseconds); // Fosa de sortida de la música (muta globals de Ja)
void setOnMusicEnded(std::function<void()> callback); // Callback disparat quan la pista actual acaba de drenar (CONV-03)
// Multiplicador de velocitat de la música actual. 1.0 = normal,
// 1.5 = un 50% més ràpid (efecte "chipmunk" — també puja el to).
// Es reseteja a 1.0 implícitament a cada `playMusic`. No-op si no
// hi ha música activa.
void setMusicSpeed(float ratio);
// --- Control de sons ---
void playSound(const std::string& name, Group group = Group::GAME); // Reproduir so puntual per nom (muta globals de Ja)
void playSound(Ja::Sound* sound, Group group = Group::GAME); // Reproduir so puntual per punter (muta globals de Ja)
// Reprodueix un so processat per un efecte definit a data/sounds.yaml
// Reprodueix un so processat per un efecte definit a data/config/sounds.yaml
// (preset_name busca a SoundEffectsConfig). Si el preset no existeix
// o el motor està al cap de canals amb efecte, fa fallback a playSound
// sec — l'usuari sent el so igualment, sense la cua.
+3 -3
View File
@@ -5,10 +5,10 @@
#include <cmath>
#include <cstdint>
#include <cstring>
#include <iostream>
#include <vector>
#include "core/audio/jail_audio.hpp"
#include "utils/log.hpp"
namespace AudioEffects {
@@ -46,11 +46,11 @@ namespace AudioEffects {
} else if (spec.format == SDL_AUDIO_U8) {
bytes_per_sample = 1;
} else {
std::cerr << "AudioEffects: format de so no suportat (només U8 o S16)\n";
Log::error("AudioEffects", "format de so no suportat (només U8 o S16)");
return false;
}
if (spec.channels < 1 || spec.channels > 2) {
std::cerr << "AudioEffects: el so ha de ser mono o estèreo\n";
Log::error("AudioEffects", "el so ha de ser mono o estèreo");
return false;
}
+5
View File
@@ -286,6 +286,11 @@ namespace Ja {
}
}
void Engine::setMusicSpeed(float ratio) {
if (current_music_ == nullptr || current_music_->stream == nullptr) { return; }
SDL_SetAudioStreamFrequencyRatio(current_music_->stream, ratio);
}
void Engine::pauseMusic() {
if (current_music_ == nullptr || current_music_->state != MusicState::PLAYING) { return; }
+8
View File
@@ -171,6 +171,14 @@ namespace Ja {
void crossfadeMusic(Music* music, int crossfade_ms, int loop = -1);
[[nodiscard]] auto getMusicState() const -> MusicState;
auto setMusicVolume(float volume) -> float;
// Multiplicador de velocitat de reproducció de la música actual
// via `SDL_SetAudioStreamFrequencyRatio`. 1.0 = normal, 2.0 =
// doble velocitat. Cal saber que també puja el to (efecte
// "chipmunk") — és el comportament arcade clàssic dels comptes
// enrere. Cada `playMusic` crea un stream nou amb ratio 1.0,
// així que un canvi de track reseteja la velocitat
// implícitament. No-op si no hi ha música activa.
void setMusicSpeed(float ratio);
// Registra un callback que es disparà quan la música actual acabi de
// drenar naturalment (times == 0 + stream buit). Es crida DESPRÉS de
// stopMusic, així que el callback pot invocar playMusic sense córrer.
+4 -4
View File
@@ -1,10 +1,10 @@
#include "core/audio/sound_effects_config.hpp"
#include <iostream>
#include <string>
#include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp"
#include "utils/log.hpp"
namespace {
@@ -27,7 +27,7 @@ auto SoundEffectsConfig::get() -> SoundEffectsConfig& {
void SoundEffectsConfig::load(const std::string& file_path) {
auto bytes = Resource::Helper::loadFile(file_path);
if (bytes.empty()) {
std::cerr << "SoundEffectsConfig: no s'ha pogut obrir " << file_path << " — cap preset d'efecte disponible\n";
Log::warn("SoundEffectsConfig", "no s'ha pogut obrir {} — cap preset d'efecte disponible", file_path);
return;
}
@@ -60,9 +60,9 @@ void SoundEffectsConfig::load(const std::string& file_path) {
}
}
std::cout << "SoundEffectsConfig: " << echoes_.size() << " preset(s) d'eco i " << reverbs_.size() << " de reverb des de " << file_path << '\n';
Log::info("SoundEffectsConfig", "{} preset(s) d'eco i {} de reverb des de {}", echoes_.size(), reverbs_.size(), file_path);
} catch (const std::exception& e) {
std::cerr << "SoundEffectsConfig: error parsejant " << file_path << ": " << e.what() << '\n';
Log::error("SoundEffectsConfig", "error parsejant {}: {}", file_path, e.what());
}
}
+1 -1
View File
@@ -5,7 +5,7 @@
#include "core/audio/jail_audio.hpp" // Per a Ja::EchoParams / Ja::ReverbParams
// Catàleg de presets d'efectes carregat des de data/sounds.yaml. La capa
// Catàleg de presets d'efectes carregat des de data/config/sounds.yaml. La capa
// Audio (playSoundWithEcho/playSoundWithReverb) hi accedeix per nom: si el
// preset no existeix, el so es reprodueix sec amb un avís a stderr.
//
+7 -10
View File
@@ -3,12 +3,12 @@
#include <algorithm>
#include <array>
#include <fstream>
#include <iostream>
#include <sstream>
#include <string_view>
#include <utility>
#include "external/fkyaml_node.hpp"
#include "utils/log.hpp"
namespace {
@@ -90,7 +90,7 @@ auto GamepadBindings::get() -> GamepadBindings& {
}
auto GamepadBindings::defaultButton(Action action) -> int {
// Defaults històrics de l'aee_2026 (vegeu Player::accumulateGamepadMovement
// Defaults històrics de l'aee_arcade (vegeu Player::accumulateGamepadMovement
// i les consultes del Director per al menú).
switch (action) {
case Action::JUMP:
@@ -167,8 +167,7 @@ void GamepadBindings::load(const std::string& file_path) {
try {
auto yaml = fkyaml::node::deserialize(CONTENT);
if (!yaml.contains("gamepads") || !yaml["gamepads"].is_sequence()) {
std::cerr << "GamepadBindings: secció 'gamepads' absent o invàlida a "
<< file_path << '\n';
Log::error("GamepadBindings", "secció 'gamepads' absent o invàlida a {}", file_path);
return;
}
for (const auto& entry : yaml["gamepads"]) {
@@ -188,11 +187,9 @@ void GamepadBindings::load(const std::string& file_path) {
}
configs_.push_back(std::move(cfg));
}
std::cout << "GamepadBindings: " << configs_.size() << " mandos carregats de "
<< file_path << '\n';
Log::info("GamepadBindings", "{} mandos carregats de {}", configs_.size(), file_path);
} catch (const std::exception& e) {
std::cerr << "GamepadBindings: error parsejant " << file_path << ": "
<< e.what() << '\n';
Log::error("GamepadBindings", "error parsejant {}: {}", file_path, e.what());
}
}
@@ -200,7 +197,7 @@ auto GamepadBindings::save() -> bool {
if (file_path_.empty()) { return false; }
std::ostringstream out;
out << "# AEE 2026 - Vinculacions de mando per dispositiu.\n";
out << "# AEE ARCADE - Vinculacions de mando per dispositiu.\n";
out << "# Auto-generat des del menú de servei. Editable a mà.\n";
out << "gamepads:\n";
for (const auto& cfg : configs_) {
@@ -215,7 +212,7 @@ auto GamepadBindings::save() -> bool {
std::ofstream file(file_path_, std::ios::binary);
if (!file) {
std::cerr << "GamepadBindings: no es pot escriure " << file_path_ << '\n';
Log::error("GamepadBindings", "no es pot escriure {}", file_path_);
return false;
}
file << out.str();
+12 -20
View File
@@ -1,13 +1,13 @@
#include "core/input/gamepad_manager.hpp"
#include <algorithm>
#include <iostream>
#include <iterator>
#include <string>
#include <utility>
#include "core/locale/locale.hpp"
#include "core/rendering/notification_presets.hpp"
#include "utils/log.hpp"
namespace {
@@ -94,8 +94,7 @@ void GamepadManager::addGamepad(SDL_JoystickID jid, bool notify) {
if (!SDL_IsGamepad(jid)) { return; }
SDL_Gamepad* pad = SDL_OpenGamepad(jid);
if (pad == nullptr) {
std::cout << "GamepadManager: SDL_OpenGamepad(" << jid
<< ") ha fallat: " << SDL_GetError() << '\n';
Log::error("GamepadManager", "SDL_OpenGamepad({}) ha fallat: {}", jid, SDL_GetError());
return;
}
std::string name = uniqueName(prettyName(SDL_GetGamepadName(pad)));
@@ -104,8 +103,7 @@ void GamepadManager::addGamepad(SDL_JoystickID jid, bool notify) {
// reconnexió es resoldrà per nom.
const char* raw_path = SDL_GetGamepadPath(pad);
std::string path = (raw_path != nullptr) ? raw_path : "";
std::cout << "GamepadManager: connectat " << name
<< (path.empty() ? "" : " [" + path + "]") << '\n';
Log::info("GamepadManager", "connectat {}{}", name, path.empty() ? std::string{} : " [" + path + "]");
entries_.push_back(Entry{.pad = pad, .instance_id = jid, .pretty_name = name, .path = path});
// Hot-plug: si alguna assignació pendent encaixa amb aquest mando,
@@ -129,7 +127,7 @@ void GamepadManager::removeGamepad(SDL_JoystickID jid) {
std::string name = std::move(it->pretty_name);
SDL_Gamepad* pad = it->pad;
if (pad != nullptr) { SDL_CloseGamepad(pad); }
std::cout << "GamepadManager: desconnectat " << name << '\n';
Log::info("GamepadManager", "desconnectat {}", name);
entries_.erase(it);
// Qualsevol assignació que apuntava a aquest mando queda amb `pad`
@@ -145,8 +143,7 @@ void GamepadManager::removeGamepad(SDL_JoystickID jid) {
void GamepadManager::init() {
if (initialized_) { return; }
if (!SDL_InitSubSystem(SDL_INIT_GAMEPAD)) {
std::cout << "GamepadManager: SDL_INIT_GAMEPAD ha fallat: "
<< SDL_GetError() << '\n';
Log::error("GamepadManager", "SDL_INIT_GAMEPAD ha fallat: {}", SDL_GetError());
return;
}
// `gamecontrollerdb.txt` és opcional. Viu a l'arrel del projecte
@@ -161,10 +158,9 @@ void GamepadManager::init() {
}
const int ADDED = SDL_AddGamepadMappingsFromFile(db_path.c_str());
if (ADDED < 0) {
std::cout << "GamepadManager: no s'ha pogut carregar " << db_path
<< ": " << SDL_GetError() << '\n';
Log::warn("GamepadManager", "no s'ha pogut carregar {}: {}", db_path, SDL_GetError());
} else {
std::cout << "GamepadManager: " << ADDED << " mappings carregats de " << db_path << '\n';
Log::info("GamepadManager", "{} mappings carregats de {}", ADDED, db_path);
}
int count = 0;
@@ -175,7 +171,7 @@ void GamepadManager::init() {
}
SDL_free(ids);
}
std::cout << "GamepadManager: " << entries_.size() << " mando(s) detectat(s) a l'inici\n";
Log::info("GamepadManager", "{} mando(s) detectat(s) a l'inici", entries_.size());
initialized_ = true;
}
@@ -268,8 +264,7 @@ void GamepadManager::assignToPlayer(int player_id, const std::string& name, cons
if (a.pad != nullptr) {
for (auto& other : assignments_) {
if (&other != &a && other.pad == a.pad) {
std::cout << "GamepadManager: mando " << a.name
<< " reassignat (last-wins)\n";
Log::info("GamepadManager", "mando {} reassignat (last-wins)", a.name);
other.pad = nullptr;
}
}
@@ -277,11 +272,9 @@ void GamepadManager::assignToPlayer(int player_id, const std::string& name, cons
// Traç de l'assignació persistida (vinculada o pendent) perquè quede
// visible a la consola igual que les autoassignacions.
if (a.pad != nullptr) {
std::cout << "GamepadManager: assignat " << a.name
<< " → P" << (player_id + 1) << '\n';
Log::info("GamepadManager", "assignat {} → P{}", a.name, player_id + 1);
} else {
std::cout << "GamepadManager: P" << (player_id + 1) << " pendent ("
<< (a.name.empty() ? a.path : a.name) << " no connectat)\n";
Log::info("GamepadManager", "P{} pendent ({} no connectat)", player_id + 1, a.name.empty() ? a.path : a.name);
}
}
@@ -307,8 +300,7 @@ auto GamepadManager::autoAssignFreeSlots() -> std::vector<AutoAssigned> {
slot.name = e.pretty_name;
slot.path = e.path;
assigned.push_back(AutoAssigned{.player_id = pid, .name = e.pretty_name, .path = e.path});
std::cout << "GamepadManager: auto-assigned " << e.pretty_name
<< " → P" << (pid + 1) << '\n';
Log::info("GamepadManager", "auto-assigned {} → P{}", e.pretty_name, pid + 1);
break;
}
}
+2 -2
View File
@@ -26,7 +26,7 @@ namespace GlobalInputs {
// La lliguem a la durada de la notificació EXIT perquè el missatge i
// la confirmació expiren alhora — si no, l'usuari podria veure el
// text però ja no ser dins del rang de confirmació (o al revés).
// El valor concret viu a data/ui.yaml (timings.exit_ms) via UiConfig.
// El valor concret viu a data/config/ui.yaml (timings.exit_ms) via UiConfig.
constexpr int MIN_ZOOM = 1;
constexpr int MAX_ZOOM = 6;
@@ -161,7 +161,7 @@ namespace GlobalInputs {
// Debug helper: dispara una notificació prou llarga perquè
// `wrapToWidth` la parteixi en (almenys) dues línies. Útil per a
// verificar visualment el padding asimètric, el line_gap i la caixa
// multilinia després de tocar la geometria a `data/ui.yaml`.
// multilinia després de tocar la geometria a `data/config/ui.yaml`.
void onDebugLongNotification() {
Notification::notify("Notificació de prova llarga per a comprovar que el text es parteix correctament en dues línies dins la caixa.");
}
+6 -6
View File
@@ -1,11 +1,11 @@
#include "core/input/key_config.hpp"
#include <fstream>
#include <iostream>
#include <string>
#include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp"
#include "utils/log.hpp"
auto KeyConfig::get() -> KeyConfig& {
static KeyConfig instance_;
@@ -15,7 +15,7 @@ auto KeyConfig::get() -> KeyConfig& {
void KeyConfig::load(const std::string& file_path) {
auto bytes = Resource::Helper::loadFile(file_path);
if (bytes.empty()) {
std::cerr << "KeyConfig: no s'ha pogut obrir " << file_path << '\n';
Log::error("KeyConfig", "no s'ha pogut obrir {}", file_path);
return;
}
@@ -24,7 +24,7 @@ void KeyConfig::load(const std::string& file_path) {
const auto* end = begin + bytes.size();
auto yaml = fkyaml::node::deserialize(begin, end);
if (!yaml.contains("keys") || !yaml["keys"].is_sequence()) {
std::cerr << "KeyConfig: falta la secció 'keys' a " << file_path << '\n';
Log::error("KeyConfig", "falta la secció 'keys' a {}", file_path);
return;
}
bindings_.clear();
@@ -34,14 +34,14 @@ void KeyConfig::load(const std::string& file_path) {
const auto CODE = entry["code"].get_value<std::string>();
const SDL_Scancode SC = SDL_GetScancodeFromName(CODE.c_str());
if (SC == SDL_SCANCODE_UNKNOWN) {
std::cerr << "KeyConfig: codi SDL desconegut '" << CODE << "' per id '" << ID << "'\n";
Log::warn("KeyConfig", "codi SDL desconegut '{}' per id '{}'", CODE, ID);
continue;
}
bindings_[ID] = SC;
}
std::cout << "KeyConfig: " << bindings_.size() << " bindings des de " << file_path << '\n';
Log::info("KeyConfig", "{} bindings des de {}", bindings_.size(), file_path);
} catch (const std::exception& e) {
std::cerr << "KeyConfig: error parsejant " << file_path << ": " << e.what() << '\n';
Log::error("KeyConfig", "error parsejant {}: {}", file_path, e.what());
}
}
+1 -1
View File
@@ -5,7 +5,7 @@
#include <string>
#include <unordered_map>
// Mapeig id → SDL_Scancode carregat des de data/input/keys.yaml. Consumit
// Mapeig id → SDL_Scancode carregat des de data/config/input.yaml. Consumit
// per GlobalInputs i (en el futur) per menús de remapat. No té cap cost
// global: si la clau no existeix, retorna SDL_SCANCODE_UNKNOWN i GlobalInputs
// ignora l'acció. D'aquesta manera podem afegir nous bindings al YAML sense
+5 -5
View File
@@ -1,11 +1,11 @@
#include "core/locale/locale.hpp"
#include <fstream>
#include <iostream>
#include <string>
#include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp"
#include "utils/log.hpp"
namespace {
@@ -36,7 +36,7 @@ auto Locale::get() -> Locale& {
void Locale::load(const std::string& file_path) {
auto bytes = Resource::Helper::loadFile(file_path);
if (bytes.empty()) {
std::cerr << "Locale: no s'ha pogut obrir " << file_path << '\n';
Log::error("Locale", "no s'ha pogut obrir {}", file_path);
return;
}
@@ -46,15 +46,15 @@ void Locale::load(const std::string& file_path) {
auto yaml = fkyaml::node::deserialize(begin, end);
strings_.clear();
flatten(yaml, "", strings_);
std::cout << "Locale: " << strings_.size() << " traduccions des de " << file_path << '\n';
Log::info("Locale", "{} traduccions des de {}", strings_.size(), file_path);
} catch (const std::exception& e) {
std::cerr << "Locale: error parsejant " << file_path << ": " << e.what() << '\n';
Log::error("Locale", "error parsejant {}: {}", file_path, e.what());
}
}
auto Locale::text(const std::string& key) const -> std::string {
auto it = strings_.find(key);
if (it != strings_.end()) { return it->second; }
std::cerr << "Locale: clau no trobada: " << key << '\n';
Log::warn("Locale", "clau no trobada: {}", key);
return key;
}
+6 -7
View File
@@ -1,10 +1,11 @@
#include "core/rendering/gif.hpp"
#include <cstring>
#include <iostream>
#include <stdexcept>
#include <string>
#include "utils/log.hpp"
namespace GIF {
namespace {
@@ -111,7 +112,7 @@ namespace GIF {
while (cur_code != -1) {
out[dictionary[cur_code].len - 1] = dictionary[cur_code].byte;
if (dictionary[cur_code].prev == cur_code) {
std::cerr << "Internal error; self-reference detected." << '\n';
Log::error("GIF", "internal error; self-reference detected.");
throw std::runtime_error("Internal error in decompress: self-reference");
}
cur_code = dictionary[cur_code].prev;
@@ -155,8 +156,7 @@ namespace GIF {
if (prev > -1 && code_length < LZW_MAX_CODE_LENGTH) {
if (code > dictionary_ind) {
std::cerr << "code = " << std::hex << code
<< ", but dictionary_ind = " << dictionary_ind << '\n';
Log::error("GIF", "code = {:x}, but dictionary_ind = {}", code, dictionary_ind);
throw std::runtime_error("LZW error: code exceeds dictionary_ind.");
}
@@ -167,8 +167,7 @@ namespace GIF {
// Verifica que 'code' sea un índice válido antes de usarlo.
if (code < 0 || static_cast<size_t>(code) >= dictionary.size()) {
std::cerr << "Invalid LZW code " << code
<< ", dictionary size " << dictionary.size() << '\n';
Log::error("GIF", "invalid LZW code {}, dictionary size {}", code, dictionary.size());
throw std::runtime_error("LZW error: invalid code encountered");
}
@@ -287,7 +286,7 @@ namespace GIF {
} else if (block_type == IMAGE_DESCRIPTOR) {
return processImageDescriptor(buffer);
} else {
std::cerr << "Unrecognized block type " << std::hex << static_cast<int>(block_type) << '\n';
Log::error("GIF", "unrecognized block type {:x}", static_cast<int>(block_type));
return std::vector<uint8_t>{};
}
block_type = *buffer++;
@@ -9,7 +9,7 @@
// Temes per a les notificacions de pantalla. Un "preset" captura els dos
// colors (text i fons, índexs sobre la paleta "ui") i el temps de visibilitat
// en mil·lisegons. Els valors concrets viuen a data/ui.yaml (carregat per
// en mil·lisegons. Els valors concrets viuen a data/config/ui.yaml (carregat per
// UiConfig); per llançar una notificació amb un preset concret, cridar
// Notification::notify(text, preset). Sense preset s'usa el DEFAULT.
namespace Notification {
@@ -22,7 +22,7 @@ namespace Notification {
// Preset per defecte: text clar sobre fons fosc. Es construeix en cada
// crida a partir d'UiConfig perquè els valors no són constexpr: viuen a
// data/ui.yaml i es carreguen a l'arrencada.
// data/config/ui.yaml i es carreguen a l'arrencada.
[[nodiscard]] inline auto defaultPreset() -> Preset {
return {
.text_color = UiConfig::get().notificationText(),
+3 -3
View File
@@ -6,13 +6,13 @@
#include <cstddef>
#include <cstdio>
#include <cstdlib>
#include <iostream>
#include <memory>
#include <ranges>
#include "core/rendering/canvas.hpp"
#include "core/rendering/sdl3gpu/viewport.hpp"
#include "utils/defines.hpp"
#include "utils/log.hpp"
namespace {
@@ -112,11 +112,11 @@ void Screen::createSdl() {
backend->setVSync(config_.vsync);
if (backend->init(window_, canvas_width_, canvas_height_)) {
gpu_driver_ = backend->getDriverName();
std::cout << "Screen: path GPU actiu (driver: " << gpu_driver_ << ")\n";
Log::info("Screen", "path GPU actiu (driver: {})", gpu_driver_);
shader_backend_ = std::move(backend);
return;
}
std::cerr << "Screen: GPU no disponible, fallback a SDL_Renderer\n";
Log::warn("Screen", "GPU no disponible, fallback a SDL_Renderer");
}
gpu_driver_ = "sdl";
@@ -1,10 +1,10 @@
#include "core/rendering/sdl3gpu/pipelines/crtpi_pipeline.hpp"
#include <cstring>
#include <iostream>
#include "core/rendering/sdl3gpu/shader_factory.hpp"
#include "core/rendering/sdl3gpu/viewport.hpp"
#include "utils/log.hpp"
#ifdef __APPLE__
#include "core/rendering/sdl3gpu/msl/crtpi_frag.msl.h"
@@ -29,7 +29,7 @@ namespace Rendering {
SDL_GPUShader* frag = createShaderSPIRV(device_, kcrtpi_frag_spv, kcrtpi_frag_spv_size, "main", SDL_GPU_SHADERSTAGE_FRAGMENT, 1, 1);
#endif
if (vert == nullptr || frag == nullptr) {
std::cerr << "CrtPiPipeline: creació de shaders fallida: " << SDL_GetError() << '\n';
Log::error("CrtPiPipeline", "creació de shaders fallida: {}", SDL_GetError());
if (vert != nullptr) { SDL_ReleaseGPUShader(device_, vert); }
if (frag != nullptr) { SDL_ReleaseGPUShader(device_, frag); }
return false;
@@ -57,7 +57,7 @@ namespace Rendering {
SDL_ReleaseGPUShader(device_, frag);
if (pipeline_ == nullptr) {
std::cerr << "CrtPiPipeline: creació del pipeline ha fallat: " << SDL_GetError() << '\n';
Log::error("CrtPiPipeline", "creació del pipeline ha fallat: {}", SDL_GetError());
return false;
}
@@ -2,10 +2,10 @@
#include <cstdint>
#include <cstring>
#include <iostream>
#include "core/rendering/sdl3gpu/shader_factory.hpp"
#include "core/rendering/sdl3gpu/viewport.hpp"
#include "utils/log.hpp"
#ifdef __APPLE__
#include "core/rendering/sdl3gpu/msl/postfx_frag.msl.h"
@@ -59,11 +59,11 @@ namespace Rendering {
const char* driver = preferred_driver_.empty() ? nullptr : preferred_driver_.c_str();
device_ = SDL_CreateGPUDevice(FORMAT, false, driver);
if (device_ == nullptr) {
std::cerr << "SDL3GPUShader: SDL_CreateGPUDevice ha fallat: " << SDL_GetError() << '\n';
Log::error("SDL3GPUShader", "SDL_CreateGPUDevice ha fallat: {}", SDL_GetError());
return false;
}
if (!SDL_ClaimWindowForGPUDevice(device_, window_)) {
std::cerr << "SDL3GPUShader: SDL_ClaimWindowForGPUDevice ha fallat: " << SDL_GetError() << '\n';
Log::error("SDL3GPUShader", "SDL_ClaimWindowForGPUDevice ha fallat: {}", SDL_GetError());
return false;
}
SDL_SetGPUSwapchainParameters(device_, window_, SDL_GPU_SWAPCHAINCOMPOSITION_SDR, bestPresentMode(vsync_));
@@ -82,7 +82,7 @@ namespace Rendering {
fragment_shader_ = createShaderSPIRV(device_, kpostfx_frag_spv, kpostfx_frag_spv_size, "main", SDL_GPU_SHADERSTAGE_FRAGMENT, 1, 1);
#endif
if (vertex_shader_ == nullptr || fragment_shader_ == nullptr) {
std::cerr << "SDL3GPUShader: SDL_CreateGPUShader ha fallat: " << SDL_GetError() << '\n';
Log::error("SDL3GPUShader", "SDL_CreateGPUShader ha fallat: {}", SDL_GetError());
return false;
}
return true;
@@ -105,7 +105,7 @@ namespace Rendering {
pipeline_ = SDL_CreateGPUGraphicsPipeline(device_, &info);
if (pipeline_ == nullptr) {
std::cerr << "SDL3GPUShader: SDL_CreateGPUGraphicsPipeline ha fallat: " << SDL_GetError() << '\n';
Log::error("SDL3GPUShader", "SDL_CreateGPUGraphicsPipeline ha fallat: {}", SDL_GetError());
return false;
}
return true;
@@ -137,7 +137,7 @@ namespace Rendering {
sampler_ = SDL_CreateGPUSampler(device_, &samp_info);
if (scene_texture_ == nullptr || upload_buffer_ == nullptr || sampler_ == nullptr) {
std::cerr << "SDL3GPUShader: creació de recursos GPU ha fallat: " << SDL_GetError() << '\n';
Log::error("SDL3GPUShader", "creació de recursos GPU ha fallat: {}", SDL_GetError());
return false;
}
return true;
@@ -1,12 +1,12 @@
#include "core/rendering/sprite/animated_sprite.hpp"
#include <cmath>
#include <iostream>
#include <numeric>
#include <utility>
#include "core/rendering/surface.hpp"
#include "core/resources/resource_cache.hpp"
#include "utils/log.hpp"
// El parser pure de YAML d'animacions (parseAnimationsFromYaml + helpers)
// viu a `animated_sprite_parser.cpp` perquè és testable sense Surface ni
@@ -74,10 +74,10 @@ AnimatedSprite::AnimatedSprite(const AnimationResource& cached_data) {
if (!first.frames.empty()) { setClip(first.frames[0].rect); }
}
} catch (const fkyaml::exception& e) {
std::cerr << "YAML parsing error in animation " << cached_data.name << ": " << e.what() << '\n';
Log::error("AnimatedSprite", "YAML parsing error in animation {}: {}", cached_data.name, e.what());
throw;
} catch (const std::exception& e) {
std::cerr << "Error loading animation " << cached_data.name << ": " << e.what() << '\n';
Log::error("AnimatedSprite", "Error loading animation {}: {}", cached_data.name, e.what());
throw;
}
}
@@ -140,7 +140,7 @@ void AnimatedSprite::setCurrentAnimation(const std::string& name) {
if (current_animation_ == name) { return; }
auto it = animations_.find(name);
if (it == animations_.end()) {
std::cout << "** Warning: could not find \"" << name << "\" animation" << '\n';
Log::warn("AnimatedSprite", "could not find \"{}\" animation", name);
return;
}
current_animation_ = name;
+1 -2
View File
@@ -2,7 +2,6 @@
#include <algorithm>
#include <cstring>
#include <iostream>
#include <stdexcept>
#include <vector>
@@ -63,7 +62,7 @@ Surface::Surface(const std::string& file_path) {
palette_[i] = Rendering::OPAQUE_ALPHA | rgb[i];
}
printWithDots("Surface : ", getFileName(file_path), "[ LOADED ]");
printWithDots("Surface", getFileName(file_path), "[ LOADED ]");
}
void Surface::clear(Uint8 color) {
+3 -3
View File
@@ -3,7 +3,6 @@
#include <SDL3/SDL.h>
#include <cstddef>
#include <iostream>
#include <sstream>
#include <stdexcept>
#include <string>
@@ -11,6 +10,7 @@
#include "core/rendering/sprite/sprite.hpp"
#include "core/rendering/surface.hpp"
#include "core/resources/resource_helper.hpp"
#include "utils/log.hpp"
#include "utils/utils.hpp"
// Decodifica el pròxim codepoint UTF-8 de la cadena i avança 'pos' al byte
@@ -55,7 +55,7 @@ auto Text::loadTextFile(const std::string& file_path) -> std::shared_ptr<File> {
auto file_data = Resource::Helper::loadFile(file_path);
if (file_data.empty()) {
std::cerr << "Error: Fitxer no trobat " << getFileName(file_path) << '\n';
Log::error("Text File", "fitxer no trobat: {}", getFileName(file_path));
throw std::runtime_error("Fitxer no trobat: " + getFileName(file_path));
}
@@ -101,7 +101,7 @@ auto Text::loadTextFile(const std::string& file_path) -> std::shared_ptr<File> {
}
}
printWithDots("Text File : ", getFileName(file_path), "[ LOADED ]");
printWithDots("Text File", getFileName(file_path), "[ LOADED ]");
return tf;
}
+10 -10
View File
@@ -1,12 +1,12 @@
#include "core/resources/resource_cache.hpp"
#include <algorithm>
#include <iostream>
#include <stdexcept>
#include "core/audio/jail_audio.hpp"
#include "core/resources/palette_loader.hpp"
#include "core/resources/resource_helper.hpp"
#include "utils/log.hpp"
#include "utils/utils.hpp"
// Deleters dels recursos d'àudio: necessiten veure el tipus complet de
@@ -37,19 +37,19 @@ namespace Resource {
throw std::runtime_error("Cache: cannot load animation " + file_path);
}
animations_.emplace_back(AnimationResource{.name = name, .yaml_data = std::move(bytes)});
printWithDots("Animation : ", getFileName(file_path), "[ LOADED ]");
printWithDots("Animation", getFileName(file_path), "[ LOADED ]");
}
void Cache::loadPalette(const std::string& name, const std::string& file_path) {
auto palette = PaletteLoader::loadFromFile(file_path);
palettes_.emplace_back(PaletteResource{.name = name, .palette = palette});
printWithDots("Palette : ", getFileName(file_path), "[ LOADED ]");
printWithDots("Palette", getFileName(file_path), "[ LOADED ]");
}
auto Cache::getSurface(const std::string& name) -> std::shared_ptr<Surface> {
auto it = std::ranges::find_if(surfaces_, [&name](const auto& s) { return s.name == name; });
if (it == surfaces_.end()) {
std::cerr << "Cache: surface not found: " << name << '\n';
Log::error("Cache", "surface not found: {}", name);
throw std::runtime_error("Surface not found: " + name);
}
return it->surface;
@@ -58,7 +58,7 @@ namespace Resource {
auto Cache::getAnimationData(const std::string& name) -> const AnimationResource& {
auto it = std::ranges::find_if(animations_, [&name](const auto& a) { return a.name == name; });
if (it == animations_.end()) {
std::cerr << "Cache: animation not found: " << name << '\n';
Log::error("Cache", "animation not found: {}", name);
throw std::runtime_error("Animation not found: " + name);
}
return *it;
@@ -67,7 +67,7 @@ namespace Resource {
auto Cache::getPalette(const std::string& name) -> const Palette& {
auto it = std::ranges::find_if(palettes_, [&name](const auto& p) { return p.name == name; });
if (it == palettes_.end()) {
std::cerr << "Cache: palette not found: " << name << '\n';
Log::error("Cache", "palette not found: {}", name);
throw std::runtime_error("Palette not found: " + name);
}
return it->palette;
@@ -83,7 +83,7 @@ namespace Resource {
throw std::runtime_error("Cache: Ja::loadMusic failed for " + file_path);
}
musics_.emplace_back(MusicResource{.name = name, .music = std::unique_ptr<Ja::Music, MusicDeleter>(raw)});
printWithDots("Music : ", getFileName(file_path), "[ LOADED ]");
printWithDots("Music", getFileName(file_path), "[ LOADED ]");
}
void Cache::loadSound(const std::string& name, const std::string& file_path) {
@@ -96,13 +96,13 @@ namespace Resource {
throw std::runtime_error("Cache: Ja::loadSound failed for " + file_path);
}
sounds_.emplace_back(SoundResource{.name = name, .sound = std::unique_ptr<Ja::Sound, SoundDeleter>(raw)});
printWithDots("Sound : ", getFileName(file_path), "[ LOADED ]");
printWithDots("Sound", getFileName(file_path), "[ LOADED ]");
}
auto Cache::getMusic(const std::string& name) -> Ja::Music* {
auto it = std::ranges::find_if(musics_, [&name](const auto& m) { return m.name == name; });
if (it == musics_.end()) {
std::cerr << "Cache: music not found: " << name << '\n';
Log::error("Cache", "music not found: {}", name);
throw std::runtime_error("Music not found: " + name);
}
return it->music.get();
@@ -111,7 +111,7 @@ namespace Resource {
auto Cache::getSound(const std::string& name) -> Ja::Sound* {
auto it = std::ranges::find_if(sounds_, [&name](const auto& s) { return s.name == name; });
if (it == sounds_.end()) {
std::cerr << "Cache: sound not found: " << name << '\n';
Log::error("Cache", "sound not found: {}", name);
throw std::runtime_error("Sound not found: " + name);
}
return it->sound.get();
+8 -10
View File
@@ -1,11 +1,12 @@
#include "core/resources/resource_helper.hpp"
#include <fstream>
#include <iostream>
#include <string>
#include <string_view>
#include "core/resources/resource_pack.hpp"
#include "utils/defaults.hpp"
#include "utils/log.hpp"
namespace Resource::Helper {
@@ -26,14 +27,14 @@ namespace Resource::Helper {
// Silenciem si veníem de prefer_disk: aleshores caurem
// al pack i no és cap error real que el fitxer no estigui
// al disc.
if (verbose) { std::cerr << "Resource::Helper: cannot open " << FULL << '\n'; }
if (verbose) { Log::error("Resource::Helper", "cannot open {}", FULL); }
return {};
}
std::streamsize size = file.tellg();
file.seekg(0, std::ios::beg);
std::vector<std::uint8_t> data(static_cast<std::size_t>(size));
if (!file.read(reinterpret_cast<char*>(data.data()), size)) {
std::cerr << "Resource::Helper: failed to read " << FULL << '\n';
Log::error("Resource::Helper", "failed to read {}", FULL);
return {};
}
return data;
@@ -51,14 +52,11 @@ namespace Resource::Helper {
g_pack_loaded = g_pack.loadPack(pack_file);
if (g_pack_loaded) {
std::cout << "Resource::Helper: pack loaded (" << g_pack.getResourceCount()
<< " entries) from " << pack_file << '\n';
Log::info("Resource::Helper", "pack loaded ({} entries) from {}", g_pack.getResourceCount(), pack_file);
} else if (enable_fallback) {
std::cout << "Resource::Helper: no pack at " << pack_file
<< " — using filesystem fallback\n";
Log::info("Resource::Helper", "no pack at {} — using filesystem fallback", pack_file);
} else {
std::cerr << "Resource::Helper: FATAL — no pack at " << pack_file
<< " and fallback disabled\n";
Log::critical("Resource::Helper", "FATAL — no pack at {} and fallback disabled", pack_file);
return false;
}
return true;
@@ -92,7 +90,7 @@ namespace Resource::Helper {
// carpeta `data/`).
const auto FROM_PACK = [&filepath]() -> std::vector<std::uint8_t> {
if (!g_pack_loaded) { return {}; }
constexpr std::string_view PREFIX = "data/";
const std::string_view PREFIX{Defaults::Paths::DATA_ROOT};
std::string key = filepath;
if (key.starts_with(PREFIX)) { key.erase(0, PREFIX.size()); }
if (g_pack.hasResource(key)) { return g_pack.getResource(key); }
+11 -10
View File
@@ -4,11 +4,12 @@
#include <array>
#include <filesystem>
#include <fstream>
#include <iostream>
#include "utils/log.hpp"
namespace Resource {
const std::string Pack::DEFAULT_ENCRYPT_KEY = "AEE_2026_PACK_KEY";
const std::string Pack::DEFAULT_ENCRYPT_KEY = "AEE_ARCADE_PACK_KEY";
namespace {
constexpr const char* MAGIC = "AE26";
@@ -40,14 +41,14 @@ namespace Resource {
std::array<char, 4> header{};
file.read(header.data(), 4);
if (std::string(header.data(), 4) != MAGIC) {
std::cerr << "Resource::Pack: invalid pack file format (bad magic): " << pack_file << '\n';
Log::error("Resource::Pack", "invalid pack file format (bad magic): {}", pack_file);
return false;
}
std::uint32_t version = 0;
file.read(reinterpret_cast<char*>(&version), sizeof(version));
if (version != VERSION) {
std::cerr << "Resource::Pack: unsupported pack version: " << version << '\n';
Log::error("Resource::Pack", "unsupported pack version: {}", version);
return false;
}
@@ -86,7 +87,7 @@ namespace Resource {
auto Pack::savePack(const std::string& pack_file) const -> bool {
std::ofstream file(pack_file, std::ios::binary);
if (!file) {
std::cerr << "Resource::Pack: could not create pack file: " << pack_file << '\n';
Log::error("Resource::Pack", "could not create pack file: {}", pack_file);
return false;
}
@@ -120,7 +121,7 @@ namespace Resource {
auto Pack::addFile(const std::string& filename, const std::string& filepath) -> bool {
std::ifstream file(filepath, std::ios::binary | std::ios::ate);
if (!file) {
std::cerr << "Resource::Pack: could not open file: " << filepath << '\n';
Log::error("Resource::Pack", "could not open file: {}", filepath);
return false;
}
@@ -129,7 +130,7 @@ namespace Resource {
std::vector<std::uint8_t> file_data(file_size);
if (!file.read(reinterpret_cast<char*>(file_data.data()), file_size)) {
std::cerr << "Resource::Pack: could not read file: " << filepath << '\n';
Log::error("Resource::Pack", "could not read file: {}", filepath);
return false;
}
@@ -146,7 +147,7 @@ namespace Resource {
auto Pack::addDirectory(const std::string& directory) -> bool {
if (!std::filesystem::exists(directory)) {
std::cerr << "Resource::Pack: directory does not exist: " << directory << '\n';
Log::error("Resource::Pack", "directory does not exist: {}", directory);
return false;
}
@@ -169,7 +170,7 @@ namespace Resource {
const PackEntry& entry = it->second;
if (entry.offset + entry.size > data_.size()) {
std::cerr << "Resource::Pack: invalid resource data: " << filename << '\n';
Log::error("Resource::Pack", "invalid resource data: {}", filename);
return {};
}
@@ -179,7 +180,7 @@ namespace Resource {
std::uint32_t checksum = calculateChecksum(result);
if (checksum != entry.checksum) {
std::cerr << "Resource::Pack: checksum mismatch for: " << filename << '\n';
Log::error("Resource::Pack", "checksum mismatch for: {}", filename);
}
return result;
}
+68 -41
View File
@@ -4,10 +4,8 @@
#include <algorithm>
#include <any>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <iostream>
#include <memory>
#include <string>
#include <utility>
@@ -31,6 +29,7 @@
#include "core/system/event_buffer.hpp"
#include "core/ui/ui_config.hpp"
#include "game/debug_config.hpp"
#include "game/gameplay_config.hpp"
#ifdef _DEBUG
#include "game/editor/editor_config.hpp"
#endif
@@ -42,10 +41,11 @@
#include "game/scenes/title_scene.hpp"
#include "game/ui/service_menu.hpp"
#include "utils/defaults.hpp"
#include "utils/log.hpp"
Director::Director() {
if (!SDL_Init(0)) {
std::fprintf(stderr, "SDL_Init failed: %s\n", SDL_GetError());
Log::critical("Director", "SDL_Init failed: {}", SDL_GetError());
}
// Sistema de recursos: el pack viu al costat del binari (build/), el
@@ -66,12 +66,13 @@ Director::Director() {
constexpr bool ENABLE_FALLBACK = false;
#endif
if (!Resource::Helper::initializeResourceSystem(pack_file, ENABLE_FALLBACK)) {
std::fprintf(stderr, "Director: sistema de recursos no disponible; avortem\n");
Log::critical("Director", "sistema de recursos no disponible; avortem");
std::exit(1);
}
// Prepara la carpeta de sistema (~/.config/jailgames/aee_2026/) i carrega
// les opcions; si no existeix config.yaml el crea amb els defaults.
// Prepara la carpeta de sistema (per plataforma: %APPDATA% a Windows,
// ~/Library/Application Support a macOS, ~/.config a Linux) i carrega les
// opcions; si no existeix config.yaml el crea amb els defaults.
createSystemFolder();
// Gamepad: descobreix mandos presents silenciosament. Cal fer-ho ABANS
@@ -85,7 +86,7 @@ Director::Director() {
Options::setConfigFile(system_folder_ + "config.yaml");
if (!Options::loadFromFile()) {
std::cerr << "Director: no s'ha pogut carregar ni desar config.yaml a " << system_folder_ << '\n';
Log::error("Director", "no s'ha pogut carregar ni desar config.yaml a {}", system_folder_);
}
// Vinculacions de mando per dispositiu — fitxer aïllat de `config.yaml`
@@ -93,16 +94,21 @@ Director::Director() {
// el patró de coffee_crisis_arcade_edition (`gamepad_configs.json`).
GamepadBindings::get().load(system_folder_ + "gamepad_configs.yaml");
// Tweaks de jugabilitat (látigo travessa sarcs, friendly fire, col·lisió
// entre jugadors). Fitxer escrivible des del menú de servei (F9). Si
// no existeix, GameplayConfig::load el crea amb els defaults.
GameplayConfig::get().load(system_folder_ + "gameplay.yaml");
// Carrega els presets de PostFX des de shaders/postfx.yaml; si no existeix
// es crea amb presets per defecte (off, crt, ntsc).
Options::setPostFXFile(system_folder_ + Defaults::Video::POSTFX_FILE);
if (!Options::loadPostFXFromFile()) {
std::cerr << "Director: no s'ha pogut carregar ni desar postfx.yaml.\n";
Log::error("Director", "no s'ha pogut carregar ni desar postfx.yaml.");
}
// Presets del shader CRT-Pi (shaders/crtpi.yaml).
Options::setCrtPiFile(system_folder_ + Defaults::Video::CRTPI_FILE);
if (!Options::loadCrtPiFromFile()) {
std::cerr << "Director: no s'ha pogut carregar ni desar crtpi.yaml.\n";
Log::error("Director", "no s'ha pogut carregar ni desar crtpi.yaml.");
}
// Screen es configura ara (subsystem + finestra + GPU/renderer); la seua
@@ -118,22 +124,29 @@ Director::Director() {
Locale::get().load(std::string(Defaults::I18n::LOCALE_DIR) + Options::locale() + ".yaml");
// Hotkeys globals. GlobalInputs::update() les consumirà a cada frame.
KeyConfig::get().load("data/input/keys.yaml");
KeyConfig::get().load(Defaults::Paths::INPUT_YAML);
// Tema d'UI (colors, timings, fonts, flags). Qualsevol clau absent a
// data/ui.yaml cau al valor compilat a Defaults::UI.
UiConfig::get().load("data/ui.yaml");
// data/config/ui.yaml cau al valor compilat a Defaults::UI.
UiConfig::get().load(Defaults::Paths::UI_YAML);
// Configuració específica de les escenes TITLE i LOGO. Es carreguen
// additivament sobre el mateix UiConfig: cada fitxer només conté la
// seua subclau (`title:` o `logo:`), i les claus absents a la base
// (ui.yaml) o als yamls d'escena cauen al valor compilat a
// Defaults::UI::Title / Defaults::UI::Logo.
UiConfig::get().load(Defaults::Paths::TITLE_YAML);
UiConfig::get().load(Defaults::Paths::LOGO_YAML);
#ifdef _DEBUG
// Tema visual de l'editor de tilemap (Debug-only). Claus absents
// cauen als valors compilats a Defaults::Editor.
EditorConfig::get().load("data/editor/editor.yaml");
EditorConfig::get().load(Defaults::Paths::EDITOR_YAML);
#endif
// Recursos compartits d'UI (paleta + fonts). Es carreguen abans que els
// overlays perquè tots els reaprofiten sense duplicar la lògica. Els
// noms de font viuen al fitxer data/ui.yaml via UiConfig.
Resource::Cache::get().loadPalette("ui", "data/palettes/uzebox.pal");
// noms de font viuen al fitxer data/config/ui.yaml via UiConfig.
Resource::Cache::get().loadPalette("ui", Defaults::Paths::UI_PALETTE);
const std::string HUD_FONT_GIF_KEY = UiConfig::get().hudFont().name + ".gif";
Resource::Cache::get().loadSurface(HUD_FONT_GIF_KEY, UiConfig::get().hudFont().gif);
Resource::Cache::get().getSurface(HUD_FONT_GIF_KEY)->setPalette(Resource::Cache::get().getPalette("ui"));
@@ -152,33 +165,36 @@ Director::Director() {
// Pistes de música del cicle de sala (intro/playing/outro). Es preloden
// ací perquè formen part del preload d'assets, com la font i la paleta;
// la lògica de quina sona en cada estat viu a `GameScene`.
Resource::Cache::get().loadMusic("room_intro.ogg", "data/music/room_intro.ogg");
Resource::Cache::get().loadMusic("playing.ogg", "data/music/playing.ogg");
Resource::Cache::get().loadMusic("room_outro.ogg", "data/music/room_outro.ogg");
Resource::Cache::get().loadMusic("room_intro.ogg", Defaults::Paths::MUSIC_ROOM_INTRO);
Resource::Cache::get().loadMusic("playing.ogg", Defaults::Paths::MUSIC_PLAYING);
Resource::Cache::get().loadMusic("room_outro.ogg", Defaults::Paths::MUSIC_ROOM_OUTRO);
// Pista de game over: pre-load igual que la resta perquè el playMusic
// del moment en què es dispara la sub-FSM no facti disc.
Resource::Cache::get().loadMusic("game_over.ogg", Defaults::Paths::MUSIC_GAME_OVER);
// Música de la TITLE: arrenca al constructor de l'escena i es fa
// fade-out quan l'escena entra a STARTING.
Resource::Cache::get().loadMusic("title.ogg", "data/music/title.ogg");
Resource::Cache::get().loadMusic("title.ogg", Defaults::Paths::MUSIC_TITLE);
// Sons del menú de servei. Els noms venen de `data/ui.yaml` (amb
// Sons del menú de servei. Els noms venen de `data/config/ui.yaml` (amb
// fallback a `Defaults::UI::MENU_*_SOUND`); el ServiceMenu els
// dispara via Audio::Group::INTERFACE.
const auto& menu_select = UiConfig::get().menuSelectSound();
const auto& menu_accept = UiConfig::get().menuAcceptSound();
Resource::Cache::get().loadSound(menu_select, "data/sounds/" + menu_select);
Resource::Cache::get().loadSound(menu_accept, "data/sounds/" + menu_accept);
Resource::Cache::get().loadSound(menu_select, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + menu_select);
Resource::Cache::get().loadSound(menu_accept, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + menu_accept);
// So del START a la TITLE: el dispara TitleScene quan un jugador
// prem START (vore enterStarting i el reset del segon jugador).
const auto& player_start = UiConfig::get().playerStartSound();
Resource::Cache::get().loadSound(player_start, "data/sounds/" + player_start);
Resource::Cache::get().loadSound(player_start, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + player_start);
// Soroll de LogoScene (scanline reveal). El dispara LogoScene al
// ctor; amb fallback al nom de Defaults si la clau YAML no hi és.
const auto& logo_glitch = UiConfig::get().logoGlitchSound();
Resource::Cache::get().loadSound(logo_glitch, "data/sounds/" + logo_glitch);
Resource::Cache::get().loadSound(logo_glitch, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + logo_glitch);
// Catàleg de presets d'efectes per als sons. Es carrega abans d'Audio
// perquè els primers playSoundWith* (al començament del joc) ja
// troben els presets disponibles.
SoundEffectsConfig::get().load("data/sounds.yaml");
SoundEffectsConfig::get().load(Defaults::Paths::SOUNDS_YAML);
// Audio: singleton manual propietari d'un SDL audio device. Es destrueix
// explícitament a ~Director abans que Resource::Cache (Meyers) buide els
@@ -216,7 +232,7 @@ Director::Director() {
// Font del menú de servei (F9). El títol va amb la variant `display`
// i els items amb `condensed-display` (més compacte perquè caben més
// files). Els dos noms i kernings viuen a data/ui.yaml via UiConfig.
// files). Els dos noms i kernings viuen a data/config/ui.yaml via UiConfig.
auto load_menu_font = [&](const UiConfig::Font& font) {
const std::string KEY = font.name + ".gif";
Resource::Cache::get().loadSurface(KEY, font.gif);
@@ -233,7 +249,7 @@ Director::Director() {
// sobreescrivim amb la "ui" perquè el GIF ja porta els colors finals
// del logo).
constexpr const char* MINI_LOGO_KEY = "logo_jailgames_mini.gif";
Resource::Cache::get().loadSurface(MINI_LOGO_KEY, "data/logo/logo_jailgames_mini.gif");
Resource::Cache::get().loadSurface(MINI_LOGO_KEY, Defaults::Paths::LOGO_MINI_GIF);
auto mini_logo_surface = Resource::Cache::get().getSurface(MINI_LOGO_KEY);
mini_logo_surface_ = mini_logo_surface;
@@ -241,20 +257,20 @@ Director::Director() {
// conserva la seua paleta nadiua: els colors finals del logo viuen
// dins del propi GIF i no han de passar per la paleta UI.
constexpr const char* LOGO_FULL_KEY = "logo_jailgames.gif";
Resource::Cache::get().loadSurface(LOGO_FULL_KEY, "data/logo/logo_jailgames.gif");
Resource::Cache::get().loadSurface(LOGO_FULL_KEY, Defaults::Paths::LOGO_FULL_GIF);
logo_full_surface_ = Resource::Cache::get().getSurface(LOGO_FULL_KEY);
// Logotip del joc per a la TITLE (substitueix el text "Aventures en
// Egipte ARCADE"). Igual que la resta de logos, paleta nadiua —
// l'art ja porta els colors finals i no passa per la UI.
constexpr const char* GAME_LOGO_KEY = "game_logo.gif";
Resource::Cache::get().loadSurface(GAME_LOGO_KEY, "data/title/game_logo.gif");
Resource::Cache::get().loadSurface(GAME_LOGO_KEY, Defaults::Paths::TITLE_GAME_LOGO_GIF);
game_logo_surface_ = Resource::Cache::get().getSurface(GAME_LOGO_KEY);
// Fons sencer de la TITLE (320×240). Igual que el minilogo, conserva
// la seua paleta nadiua perquè el GIF ja porta els colors finals.
constexpr const char* TITLE_BG_KEY = "title_bg.gif";
Resource::Cache::get().loadSurface(TITLE_BG_KEY, "data/title/title_bg.gif");
Resource::Cache::get().loadSurface(TITLE_BG_KEY, Defaults::Paths::TITLE_BG_GIF);
auto title_bg_surface = Resource::Cache::get().getSurface(TITLE_BG_KEY);
title_bg_surface_ = title_bg_surface;
ServiceMenu::get().init(menu_title_text, menu_items_text, ui_palette);
@@ -288,8 +304,7 @@ Director::Director() {
const auto START = DebugConfig::StartScene::LOGO;
const auto STARTING_PLAYERS = DebugConfig::StartingPlayers::BOTH;
#endif
std::cout << "Director: start_scene=" << DebugConfig::toString(START)
<< " starting_players=" << DebugConfig::toString(STARTING_PLAYERS) << '\n';
Log::info("Director", "start_scene={} starting_players={}", DebugConfig::toString(START), DebugConfig::toString(STARTING_PLAYERS));
switch (START) {
case DebugConfig::StartScene::LOGO:
@@ -326,25 +341,37 @@ Director::~Director() {
}
void Director::createSystemFolder() {
// Volem la config a $XDG_CONFIG_HOME/jailgames/aee_2026/ (defecte
// ~/.config/jailgames/aee_2026/). SDL_GetPrefPath apunta a XDG_DATA_HOME
// (.local/share), que és el lloc per a dades, no per a configuració.
const char* xdg = std::getenv("XDG_CONFIG_HOME");
// Carpeta de configuració per plataforma:
// - Windows: %APPDATA%\jailgames\aee_arcade\
// - macOS: ~/Library/Application Support/jailgames/aee_arcade/
// - Linux: $XDG_CONFIG_HOME/jailgames/aee_arcade/ (defecte
// ~/.config/jailgames/aee_arcade/)
// No fem servir SDL_GetPrefPath: a Linux apunta a XDG_DATA_HOME
// (~/.local/share), que és per a dades, no per a configuració.
std::string base;
#ifdef _WIN32
const char* appdata = std::getenv("APPDATA");
base = (appdata != nullptr && *appdata != '\0') ? std::string(appdata) : ".";
#elif defined(__APPLE__)
const char* home = std::getenv("HOME");
base = (home != nullptr) ? std::string(home) + "/Library/Application Support" : ".";
#else
const char* xdg = std::getenv("XDG_CONFIG_HOME");
if (xdg != nullptr && *xdg != '\0') {
base = xdg;
} else {
const char* home = std::getenv("HOME");
base = (home != nullptr) ? std::string(home) + "/.config" : ".";
}
system_folder_ = base + "/jailgames/aee_2026/";
#endif
system_folder_ = base + "/jailgames/aee_arcade/";
std::error_code error;
std::filesystem::create_directories(system_folder_, error);
// Subcarpeta per als presets de shaders. std::filesystem és tolerant si ja existeix.
std::filesystem::create_directories(system_folder_ + "shaders/", error);
if (error) {
std::cerr << "Director: no s'ha pogut crear " << system_folder_ << ": " << error.message() << '\n';
Log::error("Director", "no s'ha pogut crear {}: {}", system_folder_, error.message());
}
}
@@ -426,7 +453,7 @@ void Director::switchSceneIfRequested() {
// l'escena en aquest mateix frame — l'usuari ha cancel·lat l'estat
// de joc explícitament i no ens interessa mantenir-ne la sortida.
if (auto menu_req = ServiceMenu::get().takePendingSceneRequest(); menu_req.has_value()) {
std::cout << "Director: scene transition (menu) → " << toString(menu_req->next) << '\n';
Log::info("Director", "scene transition (menu) → {}", toString(menu_req->next));
current_scene_ = buildScene(menu_req->next, menu_req->params);
return;
}
@@ -438,10 +465,10 @@ void Director::switchSceneIfRequested() {
if (!request.has_value()) { return; }
try {
const auto& req = std::any_cast<const ExitRequest&>(request);
std::cout << "Director: scene transition → " << toString(req.next) << '\n';
Log::info("Director", "scene transition → {}", toString(req.next));
current_scene_ = buildScene(req.next, req.params);
} catch (const std::bad_any_cast&) {
std::cerr << "Director: exit_request_ amb tipus inesperat — ignorant\n";
Log::error("Director", "exit_request_ amb tipus inesperat — ignorant");
}
}
+6 -6
View File
@@ -1,6 +1,5 @@
#include "core/ui/ui_config.hpp"
#include <iostream>
#include <string>
#include <string_view>
#include <unordered_map>
@@ -9,6 +8,7 @@
#include "external/fkyaml_node.hpp"
#include "utils/defaults.hpp"
#include "utils/easing.hpp"
#include "utils/log.hpp"
namespace {
@@ -27,7 +27,7 @@ namespace {
if (!node.contains(key)) { return; }
const int VALUE = node[key].get_value<int>();
if (VALUE < 0 || VALUE > 255) {
std::cerr << "UiConfig: índex '" << key << "'=" << VALUE << " fora de rang 0..255 — ignorat\n";
Log::warn("UiConfig", "índex '{}'={} fora de rang 0..255 — ignorat", key, VALUE);
return;
}
dst = static_cast<Uint8>(VALUE);
@@ -183,7 +183,7 @@ auto UiConfig::curveByName(const std::string& name) -> CurveFn {
const auto& reg = curveRegistry();
const auto IT = reg.find(name);
if (IT == reg.end()) {
std::cerr << "UiConfig: corba '" << name << "' desconeguda — s'usa linear\n";
Log::warn("UiConfig", "corba '{}' desconeguda — s'usa linear", name);
return &Easing::linear;
}
return IT->second;
@@ -192,7 +192,7 @@ auto UiConfig::curveByName(const std::string& name) -> CurveFn {
void UiConfig::load(const std::string& file_path) {
auto bytes = Resource::Helper::loadFile(file_path);
if (bytes.empty()) {
std::cerr << "UiConfig: no s'ha pogut obrir " << file_path << " — s'usaran els defaults compilats\n";
Log::warn("UiConfig", "no s'ha pogut obrir {} — s'usaran els defaults compilats", file_path);
return;
}
@@ -345,8 +345,8 @@ void UiConfig::load(const std::string& file_path) {
readCurve(n, "slide_out_curve", notifier_.slide_out_curve);
}
std::cout << "UiConfig: carregat des de " << file_path << '\n';
Log::info("UiConfig", "carregat des de {}", file_path);
} catch (const std::exception& e) {
std::cerr << "UiConfig: error parsejant " << file_path << ": " << e.what() << '\n';
Log::error("UiConfig", "error parsejant {}: {}", file_path, e.what());
}
}
+4 -4
View File
@@ -5,7 +5,7 @@
#include <string>
// Configuració del tema UI (índexs de paleta, timings, fonts i flags)
// carregada des de data/ui.yaml. Cada camp s'inicialitza amb el valor
// carregada des de data/config/ui.yaml. Cada camp s'inicialitza amb el valor
// de Defaults::UI com a fallback: si el fitxer falta o li manca una
// clau, el joc s'executa amb el tema compilat.
//
@@ -21,12 +21,12 @@ class UiConfig {
};
// Signatura d'una funció d'easing: t ∈ [0, 1] → valor ∈ [0, 1].
// Permet triar corbes des de data/ui.yaml per nom (cadena) i guardar
// Permet triar corbes des de data/config/ui.yaml per nom (cadena) i guardar
// el punter a la corresponent funció de `utils/easing.hpp`.
using CurveFn = auto (*)(float) -> float;
// Geometria del Notifier (popup superior). Carregada des de
// data/ui.yaml secció `notifier:`; els valors per defecte vénen de
// data/config/ui.yaml secció `notifier:`; els valors per defecte vénen de
// Defaults::UI::Notifier. El moviment és per temps (slide_ms) i no per
// velocitat, perquè una caixa més gran no triga més a entrar/eixir.
struct Notifier {
@@ -170,7 +170,7 @@ class UiConfig {
[[nodiscard]] static auto curveByName(const std::string& name) -> CurveFn;
// Sons del menú de servei. Claus de Resource::Cache::loadSound (els
// fitxers viuen a `data/sounds/`).
// fitxers viuen a `data/audio/sounds/`).
[[nodiscard]] auto menuSelectSound() const -> const std::string& { return menu_select_sound_; }
[[nodiscard]] auto menuAcceptSound() const -> const std::string& { return menu_accept_sound_; }
[[nodiscard]] auto playerStartSound() const -> const std::string& { return player_start_sound_; }
+10 -10
View File
@@ -3,11 +3,11 @@
#include <exception>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <sstream>
#include <string>
#include "external/fkyaml_node.hpp"
#include "utils/log.hpp"
namespace DebugConfig {
@@ -53,12 +53,12 @@ namespace DebugConfig {
if (!yaml.contains("start_scene")) { return StartScene::GAME; }
const auto RAW = yaml["start_scene"].get_value<std::string>();
if (!isKnown(RAW)) {
std::cerr << "debug.yaml: start_scene \"" << RAW << "\" invàlid, arrancant a GAME\n";
Log::warn("debug.yaml", "start_scene \"{}\" invàlid, arrancant a GAME", RAW);
return StartScene::GAME;
}
return fromName(RAW);
} catch (const std::exception& e) {
std::cerr << "debug.yaml: error parsejant: " << e.what() << " — arrancant a GAME\n";
Log::error("debug.yaml", "error parsejant: {} — arrancant a GAME", e.what());
return StartScene::GAME;
}
}
@@ -72,7 +72,7 @@ namespace DebugConfig {
}
const auto CONTENT = readFile(file_path);
if (CONTENT.empty()) {
std::cerr << "debug.yaml: fitxer buit a " << file_path << ", arrancant a GAME\n";
Log::warn("debug.yaml", "fitxer buit a {}, arrancant a GAME", file_path);
return StartScene::GAME;
}
return parseStartScene(CONTENT);
@@ -85,12 +85,12 @@ namespace DebugConfig {
if (!yaml.contains("starting_players")) { return StartingPlayers::BOTH; }
const auto RAW = yaml["starting_players"].get_value<std::string>();
if (!isKnownPlayers(RAW)) {
std::cerr << "debug.yaml: starting_players \"" << RAW << "\" invàlid, arrancant amb BOTH\n";
Log::warn("debug.yaml", "starting_players \"{}\" invàlid, arrancant amb BOTH", RAW);
return StartingPlayers::BOTH;
}
return playersFromName(RAW);
} catch (const std::exception& e) {
std::cerr << "debug.yaml: error parsejant starting_players: " << e.what() << " — arrancant amb BOTH\n";
Log::error("debug.yaml", "error parsejant starting_players: {} — arrancant amb BOTH", e.what());
return StartingPlayers::BOTH;
}
}
@@ -110,7 +110,7 @@ namespace DebugConfig {
if (!yaml.contains("starting_level")) { return std::nullopt; }
const auto& sl = yaml["starting_level"];
if (!sl.contains("pyramid") || !sl.contains("room")) {
std::cerr << "debug.yaml: starting_level necessita els camps `pyramid` i `room` — ignorat\n";
Log::warn("debug.yaml", "starting_level necessita els camps `pyramid` i `room` — ignorat");
return std::nullopt;
}
return StartingLevel{
@@ -118,7 +118,7 @@ namespace DebugConfig {
.room = sl["room"].get_value<int>(),
};
} catch (const std::exception& e) {
std::cerr << "debug.yaml: error parsejant starting_level: " << e.what() << " — ignorat\n";
Log::error("debug.yaml", "error parsejant starting_level: {} — ignorat", e.what());
return std::nullopt;
}
}
@@ -162,7 +162,7 @@ namespace DebugConfig {
if (std::filesystem::exists(file_path, error)) { return; }
std::ofstream file(file_path);
if (!file) {
std::cerr << "debug.yaml: no s'ha pogut crear " << file_path << '\n';
Log::error("debug.yaml", "no s'ha pogut crear {}", file_path);
return;
}
file << "# debug.yaml — ajust personal de dev (no versionat).\n";
@@ -173,7 +173,7 @@ namespace DebugConfig {
file << "# clau, comença a la primera (P1-R1).\n";
file << "start_scene: GAME\n";
file << "starting_players: BOTH\n";
std::cout << "debug.yaml: creat a " << file_path << " amb start_scene=GAME starting_players=BOTH\n";
Log::info("debug.yaml", "creat a {} amb start_scene=GAME starting_players=BOTH", file_path);
}
} // namespace DebugConfig
+4 -3
View File
@@ -5,9 +5,10 @@
#include <string>
// Configuració personal de desenvolupament, carregada de `debug.yaml` dins la
// carpeta de sistema (`$XDG_CONFIG_HOME/jailgames/aee_2026/debug.yaml`). No es
// versiona: cada dev el crea a la seua màquina. Si no existeix, s'aplica el
// valor per defecte (GAME) sense warning de nivell error.
// carpeta de sistema del joc (vegeu `Director::createSystemFolder` per la
// ubicació real per plataforma). No es versiona: cada dev el crea a la seua
// màquina. Si no existeix, s'aplica el valor per defecte (GAME) sense warning
// de nivell error.
namespace DebugConfig {
enum class StartScene : std::uint8_t { LOGO,
+1 -1
View File
@@ -234,7 +234,7 @@ void Editor::ensureEditableEntities() {
// llança si no troba — provem primer i, en cas de miss, carreguem.
const auto REL = sp["path"].get_value<std::string>();
const std::string KEY = std::filesystem::path(REL).filename().string();
std::string full = "data/entities/";
std::string full = Defaults::Paths::ENTITIES_DIR;
full += name;
full += '/';
full += REL;
+5 -5
View File
@@ -3,13 +3,13 @@
#ifdef _DEBUG
#include <cstdint>
#include <iostream>
#include <stdexcept>
#include <string>
#include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp"
#include "utils/defaults.hpp"
#include "utils/log.hpp"
namespace {
@@ -34,7 +34,7 @@ namespace {
if (!s.empty() && s.front() == '#') { s.erase(0, 1); }
dst = static_cast<std::uint32_t>(std::stoul(s, nullptr, 0));
} catch (const std::exception& e) {
std::cerr << "EditorConfig: color '" << key << "' invàlid (" << e.what() << ") — ignorat\n";
Log::warn("EditorConfig", "color '{}' invàlid ({}) — ignorat", key, e.what());
}
}
@@ -56,7 +56,7 @@ auto EditorConfig::get() -> EditorConfig& {
void EditorConfig::load(const std::string& file_path) {
auto bytes = Resource::Helper::loadFile(file_path);
if (bytes.empty()) {
std::cerr << "EditorConfig: no s'ha pogut obrir " << file_path << " — s'usaran els defaults compilats\n";
Log::warn("EditorConfig", "no s'ha pogut obrir {} — s'usaran els defaults compilats", file_path);
return;
}
try {
@@ -76,9 +76,9 @@ void EditorConfig::load(const std::string& file_path) {
readArgb(c, "picker_hover", picker_hover_argb_);
}
std::cout << "EditorConfig: carregat des de " << file_path << '\n';
Log::info("EditorConfig", "carregat des de {}", file_path);
} catch (const std::exception& e) {
std::cerr << "EditorConfig: error parsejant " << file_path << ": " << e.what() << '\n';
Log::error("EditorConfig", "error parsejant {}: {}", file_path, e.what());
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
#include <string>
// Configuració visual de l'editor de tilemap (Debug-only). Carregada des
// de data/editor/editor.yaml; les claus absents cauen als valors compilats
// de data/config/editor.yaml; les claus absents cauen als valors compilats
// a Defaults::Editor. Mateix patró que UiConfig (Meyers singleton + load).
class EditorConfig {
public:
+46 -5
View File
@@ -8,11 +8,14 @@
#include <string>
#include <utility>
#include "core/audio/audio.hpp"
#include "core/resources/resource_cache.hpp"
#include "game/entities/entity_factory.hpp"
#include "game/entities/entity_manager.hpp"
#include "game/entities/event_bus.hpp"
#include "game/entities/player.hpp"
#include "game/map/map.hpp"
#include "utils/defaults.hpp"
namespace {
@@ -133,6 +136,14 @@ Mummy::Mummy(const fkyaml::node& yaml)
if (h.contains("whippable")) {
whippable_ = h["whippable"].get_value<bool>();
}
// So opcional: el basename val tant de clau al Resource::Cache com de
// path (`Defaults::Paths::AUDIO_SOUNDS_DIR + key`). Mateix patró que
// els sons del Player. Si el camp manca, la clau queda buida i
// `enterHurt` no dispara playSound.
if (h.contains("sound")) {
hurt_config_.sound_key = h["sound"].get_value<std::string>();
Resource::Cache::get().loadSound(hurt_config_.sound_key, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + hurt_config_.sound_key);
}
}
}
@@ -211,9 +222,24 @@ void Mummy::step(float delta_time) {
const float DX = dir_x_ * speed_ * delta_time;
const float DY = dir_y_ * speed_ * delta_time;
// Si no té direcció (p.ex. YAML sense `initial_direction`), el frame és
// no-op fins que algú la injecte amb `setDirection`.
if (DX == 0.0F && DY == 0.0F) { return; }
// Sense direcció: pot ser un setup degenerat (YAML sense
// `initial_direction`) o el residu d'un HURT que va sortir amb
// `chooseHurtDirection() == NONE` (totes les direccions provades
// bloquejades). En aquest segon cas la mòmia quedaria eternament quieta
// — `setDirection(0,0)` a `enterHurt` no toca `anim_`, així que es
// veuria animant-se sense moure's. Re-decidim com a una intersecció:
// si trobem alguna perpendicular vàlida, la prenem; si no, mitja volta.
// Si tampoc, mantenim (0,0) i tornem a provar el proper frame —
// pràcticament impossible amb la geometria actual, però no enganxa.
if (DX == 0.0F && DY == 0.0F) {
auto pools = collectDirectionPools(speed_ * delta_time, /*avoid_blocked=*/true);
if (!pools.perp.empty()) {
applyChoice(chooseDirection(pools.perp));
} else if (!pools.uturn.empty()) {
applyChoice(chooseDirection(pools.uturn));
}
return;
}
// Branca creuament: si aquest frame travessa la línia central d'un
// corredor perpendicular, amarra la caixa al centre del creuament i
@@ -511,6 +537,9 @@ void Mummy::enterHurt(Direction flee_dir) {
state_ = State::HURT;
hurt_remaining_s_ = hurt_config_.duration_s;
hurt_speed_ = (hurt_config_.speed_px_per_s > 0.0F) ? hurt_config_.speed_px_per_s : speed_;
if (!hurt_config_.sound_key.empty() && Audio::get() != nullptr) {
Audio::get()->playSound(hurt_config_.sound_key, Audio::Group::GAME);
}
const Direction CHOSEN = chooseHurtDirection(flee_dir);
if (CHOSEN == Direction::NONE) {
@@ -598,8 +627,20 @@ void Mummy::hurtStep(float delta_time) {
return;
}
// Topa amb mur o sarc: surt del HURT immediatament. La IA normal
// re-decideix al pròxim step (no fem U-turn ni re-orientació aquí).
// Topa amb mur del playfield: amarra la BOX a la vora exacta abans de
// transitar a ROAMING. Sense aquest snap, el drift sub-pixel acumulat a
// la cursa (DX = -hurt_speed·dt rarament és enter al joc real, on dt ve
// de SDL_GetTicks) deixaria la BOX a un offset de 0.x px dins de
// l'última cel·la. Com que la BOX és 16×16 i els passadissos també ho
// són, qualsevol drift fa que la cantonada sobresurti cap als sarcs
// adjacents — i el `chooseHurtDirection` del proper fueteig classifica
// les perpendiculars com a bloquejades per micro-overlap, retornant
// NONE i deixant la mòmia eternament quieta. Sarc-collision durant HURT
// és un cas molt menys habitual (la cursa va sempre per corredor recte);
// si arriba a passar, l'snap al playfield no l'arregla però la branca
// de recovery a `step()` el cobreix.
if (!Map::insidePlayfield(CANDIDATE)) { snapToPlayfieldEdge(DX, DY); }
state_ = State::ROAMING;
hurt_remaining_s_ = 0.0F;
}
+2
View File
@@ -3,6 +3,7 @@
#include <climits>
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
#include "external/fkyaml_node.hpp"
@@ -35,6 +36,7 @@ class Mummy : public DynamicHazard {
struct HurtConfig {
float duration_s{0.0F};
float speed_px_per_s{0.0F};
std::string sound_key; // Buit = sense so. El fitxer viu a Defaults::Paths::AUDIO_SOUNDS_DIR.
};
explicit Mummy(const fkyaml::node& yaml);
+15 -1
View File
@@ -9,6 +9,7 @@
#include "game/entities/entity_factory.hpp"
#include "game/entities/entity_manager.hpp"
#include "game/entities/player.hpp"
#include "game/gameplay_config.hpp"
#include "game/map/map.hpp"
namespace {
@@ -174,9 +175,16 @@ void WhipAttack::computeDamageRect(Direction dir, float spawn_x, float spawn_y,
// d'atac, fins a topar amb un mur o sarc, o esgotar `range_px`.
// L'amplada perpendicular és `tile_size` (els passadissos del joc
// tenen exactament aquesta mida i l'atac és lateral).
//
// Si el tweak `whip.damage_pierces_walls` està actiu, la hitbox
// ignora els sarcòfags (passa a través del bloc complet) però
// continua respectant el `insidePlayfield`: fora de la sala no hi
// ha res a colpejar i ampliar-ho generaria visuals/edge cases
// estranys. El mur extern, doncs, sempre retalla.
const auto TILE = static_cast<float>(Map::tile_size);
const int MAX_PX = static_cast<int>(std::max(0.0F, range_px));
const auto MAX = static_cast<float>(MAX_PX);
const bool PIERCES = GameplayConfig::get().whipDamagePiercesWalls();
float length = 0.0F;
auto try_probe = [&](float l) -> bool {
@@ -198,7 +206,9 @@ void WhipAttack::computeDamageRect(Direction dir, float spawn_x, float spawn_y,
probe = {.x = spawn_x, .y = spawn_y + (MAX - l), .w = TILE, .h = l};
break;
}
return Map::insidePlayfield(probe) && !Map::collides(probe);
if (!Map::insidePlayfield(probe)) { return false; }
if (PIERCES) { return true; }
return !Map::collides(probe);
};
for (int i = 1; i <= MAX_PX; ++i) {
@@ -311,6 +321,10 @@ namespace {
WhipAttack::Direction dir,
std::unordered_set<void*>& already_hit) {
if (p == owner || !playerIsTargetable(p)) { return; }
// Tweak `combat.friendly_fire`: si està desactivat, els altres
// jugadors són immunes al látigo aliat. L'owner ja queda exclòs
// per la primera guarda; aquest filtre cobreix només cross-player.
if (!GameplayConfig::get().friendlyFire()) { return; }
if (already_hit.contains(static_cast<void*>(p))) { return; }
const auto BOX = p->getBaseBox();
if (!SDL_HasRectIntersectionFloat(&damage_rect, &BOX)) { return; }
+2 -1
View File
@@ -6,6 +6,7 @@
#include "core/resources/resource_helper.hpp"
#include "game/entities/entity.hpp"
#include "game/entities/entity_factory.hpp"
#include "utils/defaults.hpp"
namespace EntityLoader {
@@ -20,7 +21,7 @@ namespace EntityLoader {
}
auto templatePath(const std::string& name) -> std::string {
return "data/entities/" + name + "/" + name + ".yaml";
return std::string{Defaults::Paths::ENTITIES_DIR} + name + "/" + name + ".yaml";
}
} // namespace
@@ -5,7 +5,6 @@
// stand-alone) enllacen `entity_manager.cpp` sense arrossegar aquest fitxer.
#include <filesystem>
#include <iostream>
#include <memory>
#include <string>
#include <utility>
@@ -21,6 +20,8 @@
#include "game/entities/entity_manager.hpp"
#include "game/entities/player.hpp"
#include "game/map/map.hpp"
#include "utils/defaults.hpp"
#include "utils/log.hpp"
// Render centralitzat al manager per confinar la dependència cap a
// `Surface`. Les entitats exposen `getSurface()` + `getPixelRect()` i el
@@ -124,7 +125,7 @@ namespace {
if (s.contains("path")) {
const auto REL = s["path"].get_value<std::string>();
info.key = std::filesystem::path(REL).filename().string();
info.full_path = "data/entities/" + type + "/" + REL;
info.full_path = std::string{Defaults::Paths::ENTITIES_DIR} + type + "/" + REL;
info.valid = true;
}
if (s.contains("size")) {
@@ -236,12 +237,12 @@ namespace {
try {
tmpl = EntityLoader::loadTemplate(type);
} catch (const std::exception& e) {
std::cerr << "EntityManager: " << e.what() << '\n';
Log::error("EntityManager", "{}", e.what());
return nullptr;
}
auto entity = EntityFactory::instance().create(type, tmpl);
if (entity == nullptr) {
std::cerr << "EntityManager: tipus desconegut '" << type << "'\n";
Log::error("EntityManager", "tipus desconegut '{}'", type);
return nullptr;
}
sprite_out = readSpriteInfo(tmpl, type);
@@ -266,7 +267,7 @@ namespace {
namespace {
auto placeSarcophagus(EntityManager& manager, std::unique_ptr<Entity> entity, const SpriteInfo& sprite, const fkyaml::node& spawn) -> bool {
if (!spawn.contains("sarc_position")) {
std::cerr << "EntityManager: sarcophagus sense sarc_position\n";
Log::error("EntityManager", "sarcophagus sense sarc_position");
return false;
}
const auto& sp = spawn["sarc_position"];
@@ -303,7 +304,7 @@ namespace {
// un `Sarcophagus`. Usant el retorn de `release` al ctor del nou
// `unique_ptr` evitem el lint `bugprone-unused-return-value`.
if (dynamic_cast<Sarcophagus*>(entity.get()) == nullptr) {
std::cerr << "EntityManager: la factory no ha creat un Sarcophagus\n";
Log::error("EntityManager", "la factory no ha creat un Sarcophagus");
return false;
}
std::unique_ptr<Sarcophagus> sarc(static_cast<Sarcophagus*>(entity.release()));
@@ -322,7 +323,7 @@ namespace {
// queden tapats — comportament intencional.
auto placeDoor(EntityManager& manager, std::unique_ptr<Entity> entity, const SpriteInfo& sprite) -> bool {
if (Map::door_col <= 0 || Map::tile_size <= 0) {
std::cerr << "EntityManager: door requereix `geometry.door_col` definit al mapa\n";
Log::error("EntityManager", "door requereix `geometry.door_col` definit al mapa");
return false;
}
const float CENTER_X = static_cast<float>((Map::door_col * Map::tile_size) + Map::tilemap_origin_x) + (static_cast<float>(Map::tile_size) / 2.0F);
@@ -342,7 +343,7 @@ namespace {
auto placeGeneric(EntityManager& manager, std::unique_ptr<Entity> entity, const std::string& type, const SpriteInfo& sprite, const std::string& anchor, const fkyaml::node& spawn) -> bool {
if (!spawn.contains("position")) {
std::cerr << "EntityManager: " << type << " sense position\n";
Log::error("EntityManager", "{} sense position", type);
return false;
}
const auto& p = spawn["position"];
@@ -443,10 +444,8 @@ void EntityManager::loadFromLevel(const std::string& level_yaml_path) {
++decal_broadcast;
}
if (decal_broadcast > 0) {
std::cout << "EntityManager: " << decal_broadcast
<< " tile_painted broadcast inicial (decals de sòl)\n";
Log::info("EntityManager", "{} tile_painted broadcast inicial (decals de sòl)", decal_broadcast);
}
std::cout << "EntityManager: " << LOADED << " entitats carregades des de "
<< level_yaml_path << '\n';
Log::info("EntityManager", "{} entitats carregades des de {}", LOADED, level_yaml_path);
}
+45 -10
View File
@@ -22,6 +22,7 @@
#include "game/entities/entity.hpp"
#include "game/entities/entity_manager.hpp"
#include "game/entities/event_bus.hpp"
#include "game/gameplay_config.hpp"
#include "game/options.hpp"
#include "utils/defaults.hpp"
@@ -179,16 +180,16 @@ void Player::readJumpBlock(const fkyaml::node& yaml) {
if (j.contains("gravity")) { jump_config.gravity_px_per_s2 = j["gravity"].get_value<float>(); }
if (j.contains("landing_stun_ms")) { jump_config.landing_stun_ms = j["landing_stun_ms"].get_value<int>(); }
if (j.contains("shadow_y_offset")) { jump_config.shadow_y_offset = j["shadow_y_offset"].get_value<int>(); }
// Sons opcionals: el nom del fitxer (relatiu a data/sounds/) val tant
// de clau al Resource::Cache com de basename per a la càrrega. Si el
// camp manca, la clau queda buida i `playSound` no es crida.
// Sons opcionals: el nom del fitxer (relatiu a Defaults::Paths::AUDIO_SOUNDS_DIR)
// val tant de clau al Resource::Cache com de basename per a la càrrega.
// Si el camp manca, la clau queda buida i `playSound` no es crida.
if (j.contains("sound")) {
jump_config.sound_key = j["sound"].get_value<std::string>();
Resource::Cache::get().loadSound(jump_config.sound_key, "data/sounds/" + jump_config.sound_key);
Resource::Cache::get().loadSound(jump_config.sound_key, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + jump_config.sound_key);
}
if (j.contains("land_sound")) {
jump_config.land_sound_key = j["land_sound"].get_value<std::string>();
Resource::Cache::get().loadSound(jump_config.land_sound_key, "data/sounds/" + jump_config.land_sound_key);
Resource::Cache::get().loadSound(jump_config.land_sound_key, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + jump_config.land_sound_key);
}
}
@@ -201,7 +202,7 @@ void Player::readHurtBlock(const fkyaml::node& yaml) {
// So d'entrada a HURT — mateix patró que `jump.sound` i `whip.sound`.
if (h.contains("sound")) {
hurt_config.sound_key = h["sound"].get_value<std::string>();
Resource::Cache::get().loadSound(hurt_config.sound_key, "data/sounds/" + hurt_config.sound_key);
Resource::Cache::get().loadSound(hurt_config.sound_key, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + hurt_config.sound_key);
}
}
@@ -220,10 +221,11 @@ void Player::readImmunityBlock(const fkyaml::node& yaml) {
if (im.contains("duration_ms")) { immunity_config.duration_ms = im["duration_ms"].get_value<float>(); }
if (im.contains("blink_period_ms")) { immunity_config.blink_period_ms = im["blink_period_ms"].get_value<int>(); }
// Paleta del blink: el basename val tant de clau al Resource::Cache
// com de path (`data/player/<key>.pal`). Mateix patró que els sons.
// com de path (`Defaults::Paths::PLAYER_DIR + key + .pal`). Mateix patró
// que els sons.
if (im.contains("palette")) {
immunity_config.palette_key = im["palette"].get_value<std::string>();
Resource::Cache::get().loadPalette(immunity_config.palette_key, "data/player/" + immunity_config.palette_key + ".pal");
Resource::Cache::get().loadPalette(immunity_config.palette_key, std::string{Defaults::Paths::PLAYER_DIR} + immunity_config.palette_key + ".pal");
}
}
@@ -239,7 +241,7 @@ void Player::readWhipBlock(const fkyaml::node& yaml) {
// clau al cache i de path. Si manca, queda buit i no es dispara.
if (w.contains("sound")) {
whip_config.sound_key = w["sound"].get_value<std::string>();
Resource::Cache::get().loadSound(whip_config.sound_key, "data/sounds/" + whip_config.sound_key);
Resource::Cache::get().loadSound(whip_config.sound_key, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + whip_config.sound_key);
}
}
@@ -413,6 +415,10 @@ auto Player::collidesWithWorld(const SDL_FRect& box) const -> bool {
// al check actor↔actor més avall via el predicat `isOutOfPlay`.
if (isOutOfPlay()) { return false; }
if (map_.collides(box)) { return true; }
// Tweak `players.collide_with_each_other`: si està desactivat, els
// jugadors es travessen lliurement. La col·lisió contra el món
// (murs/sarcs) ja s'ha avaluat dalt; saltem només la part actor↔actor.
if (!GameplayConfig::get().playersCollide()) { return false; }
if (all_players_ == nullptr) { return false; }
// Per al contacte actor-actor usem la hit-box petita a banda i banda:
// projectem la hit-box des de la posició base proposada i la comparem
@@ -1026,6 +1032,12 @@ void Player::resolveLandingOverlap() {
// canvi, qualsevol jugador amb el qual solape entra en HURT i
// surt projectat. Així el solapament inicial es resol per moviment
// del solapat, no per desplaçament forçat al lloc.
//
// Tweak `players.collide_with_each_other`: si està desactivat, el
// saltador travessa l'altre net en lloc de projectar-lo. Aquest
// camí és independent de `collidesWithWorld`, així que cal aturar-lo
// explícitament aquí.
if (!GameplayConfig::get().playersCollide()) { return; }
if (all_players_ == nullptr) { return; }
for (Player* other : *all_players_) {
if (other == nullptr || other == this) { continue; }
@@ -1258,11 +1270,30 @@ void Player::applyDyingMovement(float delta_time) {
}
}
void Player::resetForJoin() {
lives_remaining_ = death_config.initial_lives;
}
void Player::respawnFromDying() {
// Vides: decreix sempre. Pot anar a negatius — no hi ha game over.
// Vides: decreix una unitat. Si el comptador arriba a 0 el jugador
// surt de partida (DISABLED) i la GameScene/HUD reaccionen via bus
// — això habilita el flux de "join in progress" (l'altre jugador
// pot pulsar START per entrar) i obrirà el camí al CONTINUE? +
// GAME OVER en una fase posterior.
--lives_remaining_;
death_animation_complete_ = false;
respawn_remaining_ms_ = 0.0F;
if (lives_remaining_ <= 0) {
disable();
if (bus_ != nullptr) {
bus_->publish(Event{
.name = "player_disabled",
.payload = std::monostate{},
.player_id = id_,
});
}
return;
}
// Cinemàtica idèntica a la d'INTRO de sala: el jugador cau des de
// dalt fins al passadís d'spawn (`spawnFromAir` reseteja primer i
// després posa AIRBORNE amb l'altura derivada de la geometria).
@@ -1736,6 +1767,10 @@ auto Player::tryPushSnapBlockers(
float (Player::*get_pos)() const,
void (Player::*set_pos)(float)) -> bool {
if (all_players_ == nullptr) { return false; }
// Si el tweak de col·lisió entre jugadors està desactivat, mai hi
// ha res a empentar — els jugadors són intangibles entre si i el
// snap pot procedir lliurement sense desplaçar ningú.
if (!GameplayConfig::get().playersCollide()) { return false; }
// Blockers = jugadors que ara mateix solapen la nostra hit-box (ja
// amb el snap aplicat). Mateix criteri actor-actor que `collidesWithWorld`.
+34 -14
View File
@@ -91,7 +91,7 @@ class Player : public AnimatedSprite {
DYING,
};
// Geometria carregada des del YAML del player (p.ex. `data/player/sam.yaml`).
// Geometria carregada des del YAML del player (p.ex. `data/entities/player/sam.yaml`).
// `sprite_*` són
// la mida del frame (sprite 12×24 amb perspectiva falsa: visualment, els
// 12 píxels superiors són el cap i els 12 inferiors la base).
@@ -127,6 +127,11 @@ class Player : public AnimatedSprite {
// Ha de cridar-se abans d'instanciar Player; GameScene ho fa al ctor.
static void loadFrom(const std::string& yaml_path);
// Vides amb què arrenca un jugador (camp `lives:` del YAML del
// jugador). El consumeix el HUD per inicialitzar el comptador del
// marcador i `Player::resetForJoin` per a re-entrades mid-partida.
[[nodiscard]] static auto getInitialLives() -> int { return death_config.initial_lives; }
Player(const std::string& anim_key, int id, float x, float y, const Controls& controls, const Map::Info& map_info, int paint_tile_index = -1);
// Update en una sola crida: manté l'API anterior per als tests i
@@ -296,8 +301,11 @@ class Player : public AnimatedSprite {
// `death_spinning`, posició congelada, input ignorat. Idempotent —
// si ja està DYING, EXITED o DISABLED, és no-op (un segon toc de
// momia mentre la roda no la reinicia). Quan l'animació acaba i
// `respawn_delay_ms` expira, `respawn(spawn_x_, spawn_y_)` torna a
// GROUNDED i `lives_remaining_` decreix en 1 (pot anar a negatius).
// `respawn_delay_ms` expira, `respawnFromDying()` decrementa
// `lives_remaining_`: si encara queden vides, fa `spawnFromAir`
// al spawn point; si arriba a 0, fa `disable()` i publica al bus
// `player_disabled` perquè GameScene/HUD reaccionin (slot fora de
// partida, esperant un nou START per re-entrar via "join in progress").
void enterDying(DeathCause cause = DeathCause::GENERIC);
// Coordenades del spawn de respawn (cantó superior-esquerra del
@@ -309,9 +317,11 @@ class Player : public AnimatedSprite {
spawn_y_ = y;
}
// Comptador d'intents (estil Mario, NO HP). Decreix a cada respawn;
// no es clampa a 0 — permet anar a negatius perquè avui no hi ha
// game over. Públic per a HUD i tests.
// Comptador d'intents (estil Mario, NO HP). Decreix a cada respawn
// post-mort. En arribar a 0 el jugador passa a DISABLED i publica
// `player_disabled` al bus; pot tornar a partida via "join in
// progress" (GameScene::joinPlayer crida `resetForJoin`). Públic
// per a HUD i tests.
[[nodiscard]] auto getLivesRemaining() const -> int { return lives_remaining_; }
// Activa la immunitat per `duration_ms` ms. Mentre `isImmune()` és
@@ -342,6 +352,14 @@ class Player : public AnimatedSprite {
// sala usa `disable()` + `spawnFromAir()`.
void respawn(float x, float y);
// Restaura el comptador de vides al valor inicial configurat
// (`death_config.initial_lives`). Cridat per la GameScene quan un
// slot inactiu pulsa START per entrar mid-partida (regla 6 de la
// FSM de joc): el jugador entra amb les vides plenes. La resta de
// l'estat (posició, animació, flags) la reseteja `spawnFromAir`
// que GameScene crida tot seguit.
void resetForJoin();
// Posa el jugador en el slot inactiu (DISABLED). Idempotent: la
// segona crida és no-op. Mateix patró que `exit()` per simetria.
// No publica res al bus — el GameScene decideix quan disparar el
@@ -362,7 +380,7 @@ class Player : public AnimatedSprite {
// al respawn post-mort: mateixa entrada cinemàtica.
void spawnFromAir(float x, float y);
// Arma un preset de reverb (catàleg a `data/sounds.yaml`) per al
// Arma un preset de reverb (catàleg a `data/config/sounds.yaml`) per al
// pròxim aterratge d'aquest jugador. Pensat per a la cinemàtica
// d'INTRO de sala: GameScene crida aquesta funció just després de
// `spawnFromAir`, i el primer LAND consumeix el preset (queda buit
@@ -529,7 +547,7 @@ class Player : public AnimatedSprite {
// gestiona `intended_vx_/vy_` escalats pel resolver.
bool being_pushed_{false};
// Preset de reverb (catàleg de `data/sounds.yaml`) que el pròxim
// Preset de reverb (catàleg de `data/config/sounds.yaml`) que el pròxim
// LAND ha de fer servir; buit = so sec. L'arma `armNextLandReverb`
// (cridada per GameScene al spawn aeri d'INTRO) i el consumeix
// `integrateJump` quan el jugador toca terra. Cap altre punt
@@ -636,7 +654,7 @@ class Player : public AnimatedSprite {
struct HurtConfig {
float initial_speed_px_per_s{0.0F}; // Velocitat amb què surt projectat (px/s).
int brake_time_ms{0}; // Temps en que la velocitat decau a 0.
// Fitxer .wav reproduït en entrar a HURT (relatiu a `data/sounds/`).
// Fitxer .wav reproduït en entrar a HURT (relatiu a `data/audio/sounds/`).
// Mateixa convenció que la resta de sons del jugador: el basename val
// de clau al Resource::Cache i de path. Buit o absent = entrada
// silenciosa.
@@ -660,7 +678,7 @@ class Player : public AnimatedSprite {
int cooldown_ms{0};
int damage{0};
// Fitxer .wav reproduït al moment de disparar el swing (relatiu a
// `data/sounds/`). Convenció: el basename val tant de clau al
// `data/audio/sounds/`). Convenció: el basename val tant de clau al
// Resource::Cache com de path. Buit o absent = swing silenciós.
std::string sound_key;
};
@@ -697,7 +715,7 @@ class Player : public AnimatedSprite {
float duration_ms{0.0F};
int blink_period_ms{0};
// Clau al Resource::Cache de la paleta plana usada al blink (basename
// del .pal a `data/player/`). Buida = no hi ha alternança visual,
// del .pal a `data/entities/player/`). Buida = no hi ha alternança visual,
// només la finestra lògica d'immunitat (que sense feedback visual
// pot ser confusa, però no és incorrecte).
std::string palette_key;
@@ -706,7 +724,7 @@ class Player : public AnimatedSprite {
// Llig el bloc `immunity:` del YAML i n'omple `immunity_config`. Si
// hi ha `palette`, registra la paleta corresponent al Resource::Cache
// (path: `data/player/<palette_key>.pal`). Mateix patró que els altres
// (path: `data/entities/player/<palette_key>.pal`). Mateix patró que els altres
// readers — `loadFrom` el crida al final.
static void readImmunityBlock(const fkyaml::node& yaml);
@@ -829,8 +847,10 @@ class Player : public AnimatedSprite {
// Comptador d'intents (estil Mario). Inicialitzat a `death_config.
// initial_lives` al ctor; decreix una vegada per cada respawn
// post-mort (no per respawns de canvi de sala). Pot anar a negatius:
// avui no hi ha game over.
// post-mort (no per respawns de canvi de sala). En arribar a 0,
// `respawnFromDying` posa el jugador a DISABLED i publica
// `player_disabled` al bus. `resetForJoin()` el restaura al valor
// inicial quan el jugador re-entra mid-partida.
int lives_remaining_{0};
// Coordenades del spawn de respawn (cantó superior-esquerra del
+163
View File
@@ -0,0 +1,163 @@
#include "game/gameplay_config.hpp"
#include <exception>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include "external/fkyaml_node.hpp"
#include "utils/log.hpp"
namespace {
auto readFile(const std::string& path) -> std::string {
std::ifstream file(path);
if (!file) { return {}; }
std::stringstream buffer;
buffer << file.rdbuf();
return buffer.str();
}
// Llig una subclau booleana obligatòria (`section.key`). Retorna
// false si la secció o la clau no hi són o el tipus no és bool —
// qualsevol d'aquests casos és una irregularitat que dispara
// reescriptura del fitxer.
auto readRequiredBool(const fkyaml::node& root, const char* section, const char* key, bool& out)
-> bool {
if (!root.contains(section)) { return false; }
const auto& sec = root[section];
if (!sec.contains(key)) { return false; }
try {
out = sec[key].get_value<bool>();
return true;
} catch (const std::exception&) {
return false;
}
}
// Escriu el fitxer amb els valors actuals + comentaris d'ajuda.
// Format estable perquè l'usuari pot editar-lo a mà.
void writeFile(const std::string& path,
int version,
bool whip_pierces,
bool friendly_fire,
bool players_collide) {
std::ofstream file(path);
if (!file) {
Log::error("gameplay.yaml", "no s'ha pogut escriure {}", path);
return;
}
file << "# gameplay.yaml — tweaks de jugabilitat persistents.\n";
file << "# Edita des del menú de servei (F9 → Tweaks) o a mà.\n";
file << "# Si esborres el fitxer (o aquesta capçalera de versió), es regenerarà\n";
file << "# amb els defaults a la propera arrencada.\n";
file << "\n";
file << "version: " << version << "\n";
file << "\n";
file << "whip:\n";
file << " damage_pierces_walls: " << (whip_pierces ? "true" : "false") << "\n";
file << "\n";
file << "combat:\n";
file << " friendly_fire: " << (friendly_fire ? "true" : "false") << "\n";
file << "\n";
file << "players:\n";
file << " collide_with_each_other: " << (players_collide ? "true" : "false") << "\n";
}
} // namespace
auto GameplayConfig::get() -> GameplayConfig& {
static GameplayConfig instance_;
return instance_;
}
void GameplayConfig::load(const std::string& file_path) {
file_path_ = file_path;
std::error_code error;
const bool EXISTS = std::filesystem::exists(file_path, error);
const auto CONTENT = EXISTS ? readFile(file_path) : std::string{};
// Tots els valors van a temporaris; només ens els quedem si la
// validació completa passa. Així una validació parcial (p. ex. dues
// claus bones i una de dolenta) no deixa l'estat a mig camí.
bool whip_pierces = whip_damage_pierces_walls_;
bool ff = friendly_fire_;
bool players_collide = players_collide_;
auto rewrite_with_defaults = [&](const char* reason) {
// Resetem els camps als defaults compilats abans de reescriure.
// Necessari si load() es crida diverses vegades amb fitxers
// distints; el primer pot haver mutat l'estat.
whip_damage_pierces_walls_ = true;
friendly_fire_ = true;
players_collide_ = true;
writeFile(file_path,
CURRENT_VERSION,
whip_damage_pierces_walls_,
friendly_fire_,
players_collide_);
Log::info("gameplay.yaml", "{} — reescrit a {} amb defaults", reason, file_path);
};
if (!EXISTS) {
rewrite_with_defaults("fitxer absent");
return;
}
if (CONTENT.empty()) {
rewrite_with_defaults("fitxer buit");
return;
}
fkyaml::node yaml;
try {
yaml = fkyaml::node::deserialize(CONTENT);
} catch (const std::exception& e) {
rewrite_with_defaults((std::string("parse error: ") + e.what()).c_str());
return;
}
// `version` ha de ser un enter present i igual a CURRENT_VERSION.
// Qualsevol altra cosa (absent, tipus no enter, valor diferent) és
// una irregularitat: el fitxer ve d'una versió incompatible o ha
// estat manipulat de manera inesperada.
if (!yaml.contains("version")) {
rewrite_with_defaults("version absent");
return;
}
int version = 0;
try {
version = yaml["version"].get_value<int>();
} catch (const std::exception&) {
rewrite_with_defaults("version no és enter");
return;
}
if (version != CURRENT_VERSION) {
rewrite_with_defaults("version desconeguda");
return;
}
if (!readRequiredBool(yaml, "whip", "damage_pierces_walls", whip_pierces) || !readRequiredBool(yaml, "combat", "friendly_fire", ff) || !readRequiredBool(yaml, "players", "collide_with_each_other", players_collide)) {
rewrite_with_defaults("clau requerida absent o de tipus incorrecte");
return;
}
// Tot validat: ens quedem els valors llegits.
whip_damage_pierces_walls_ = whip_pierces;
friendly_fire_ = ff;
players_collide_ = players_collide;
}
void GameplayConfig::save() const {
if (file_path_.empty()) {
// Sense path establert: cap consumidor s'ha registrat encara.
// Pot passar en tests que no carreguen el fitxer; silenciem el cas.
return;
}
writeFile(file_path_,
CURRENT_VERSION,
whip_damage_pierces_walls_,
friendly_fire_,
players_collide_);
}
+71
View File
@@ -0,0 +1,71 @@
#pragma once
#include <string>
// Tweaks de jugabilitat persistents per usuari. Carregat de
// `gameplay.yaml` dins la carpeta de sistema (vegeu
// `Director::createSystemFolder` per la ubicació real per plataforma).
//
// Diferent d'UiConfig (data/config/ui.yaml, només lectura): aquest fitxer
// és escrivible i el ServiceMenu en modifica els valors. Els defaults
// compilats són els que veuen els nous usuaris fins que toquen el menú.
//
// Política d'integritat: davant qualsevol irregularitat (fitxer absent,
// parse error, `version` absent o diferent, clau requerida absent o de
// tipus incorrecte), `load()` reseteja a defaults i reescriu el fitxer.
// No es preserven fitxers parcialment vàlids — així evitem dragar
// versions antigues incoherents quan l'esquema evolucioni.
class GameplayConfig {
public:
// Esquema actual del fitxer. Incrementar quan canviï l'estructura
// (renomenar/treure claus o canviar semàntica). Els fitxers d'usuari
// amb una `version` distinta es reescriuen amb els defaults nous.
// Afegir noves claus opcionals no requereix bump si els defaults
// compilats són equivalents al comportament anterior.
static constexpr int CURRENT_VERSION = 1;
static auto get() -> GameplayConfig&;
GameplayConfig(const GameplayConfig&) = delete;
GameplayConfig(GameplayConfig&&) = delete;
auto operator=(const GameplayConfig&) -> GameplayConfig& = delete;
auto operator=(GameplayConfig&&) -> GameplayConfig& = delete;
// Llig el fitxer i guarda la ruta per a `save()`. Si no existeix,
// l'escriu amb els defaults compilats. Idempotent: pot cridar-se
// múltiples vegades (l'última crida fixa el path actiu).
void load(const std::string& file_path);
// Persisteix l'estat actual al path passat a `load()`. Cridat pel
// ServiceMenu a cada canvi de toggle. No-op si `load()` no s'ha
// cridat encara (sense path no podem escriure enlloc útil).
void save() const;
// Tweaks. Defaults: tots true.
//
// - whip_damage_pierces_walls: el dany del látigo travessa sarcòfags
// (NO el mur extern del playfield, que retalla igualment per
// evitar que el rect surti de la sala visible).
// - friendly_fire: un latigazo pot impactar l'altre jugador.
// - players_collide: els jugadors col·lisionen físicament entre si
// en lloc de travessar-se.
[[nodiscard]] auto whipDamagePiercesWalls() const -> bool { return whip_damage_pierces_walls_; }
[[nodiscard]] auto friendlyFire() const -> bool { return friendly_fire_; }
[[nodiscard]] auto playersCollide() const -> bool { return players_collide_; }
void setWhipDamagePiercesWalls(bool v) { whip_damage_pierces_walls_ = v; }
void setFriendlyFire(bool v) { friendly_fire_ = v; }
void setPlayersCollide(bool v) { players_collide_ = v; }
private:
GameplayConfig() = default;
~GameplayConfig() = default;
// Ruta absoluta al fitxer persistit. Buida fins que algú crida
// `load()` per primera vegada; mentre estigui buida, `save()` és no-op.
std::string file_path_;
bool whip_damage_pierces_walls_{true};
bool friendly_fire_{true};
bool players_collide_{true};
};
+7 -13
View File
@@ -1,13 +1,13 @@
#include "game/levels/level_manager.hpp"
#include <algorithm>
#include <iostream>
#include <stdexcept>
#include <utility>
#include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp"
#include "utils/defaults.hpp"
#include "utils/log.hpp"
namespace Levels {
@@ -46,8 +46,7 @@ namespace Levels {
throw std::runtime_error("LevelManager: la seqüència de " + PATH + " és buida");
}
index_ = 0;
std::cout << "LevelManager: " << entries_.size() << " sales carregades de "
<< PATH << " (començant a " << currentLabel() << ")\n";
Log::info("LevelManager", "{} sales carregades de {} (començant a {})", entries_.size(), PATH, currentLabel());
}
auto LevelManager::currentPath() const -> const std::string& {
@@ -67,22 +66,20 @@ namespace Levels {
auto LevelManager::advance() -> bool {
if (index_ + 1 >= count()) { return false; }
++index_;
std::cout << "LevelManager: avança a " << currentLabel()
<< " (" << currentPath() << ")\n";
Log::info("LevelManager", "avança a {} ({})", currentLabel(), currentPath());
return true;
}
void LevelManager::restart() {
index_ = 0;
std::cout << "LevelManager: restart a " << currentLabel() << '\n';
Log::info("LevelManager", "restart a {}", currentLabel());
}
void LevelManager::setIndex(int new_index) {
const int CLAMPED = std::clamp(new_index, 0, count() - 1);
if (CLAMPED != index_) {
index_ = CLAMPED;
std::cout << "LevelManager: setIndex(" << new_index << ") → "
<< currentLabel() << " (" << currentPath() << ")\n";
Log::info("LevelManager", "setIndex({}) → {} ({})", new_index, currentLabel(), currentPath());
}
}
@@ -90,14 +87,11 @@ namespace Levels {
for (std::size_t i = 0; i < entries_.size(); ++i) {
if (entries_[i].pyramid == pyramid && entries_[i].room == room) {
index_ = static_cast<int>(i);
std::cout << "LevelManager: starting_point P" << pyramid << "-R" << room
<< " trobat a l'índex " << i << " (" << currentPath() << ")\n";
Log::info("LevelManager", "starting_point P{}-R{} trobat a l'índex {} ({})", pyramid, room, i, currentPath());
return true;
}
}
std::cerr << "LevelManager: starting_point P" << pyramid << "-R" << room
<< " no trobat a levels.yaml — es manté l'índex actual ("
<< currentLabel() << ")\n";
Log::warn("LevelManager", "starting_point P{}-R{} no trobat a levels.yaml — es manté l'índex actual ({})", pyramid, room, currentLabel());
return false;
}
+2 -6
View File
@@ -1,11 +1,11 @@
#include "game/map/map.hpp"
#include <cmath>
#include <iostream>
#include <stdexcept>
#include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp"
#include "utils/log.hpp"
namespace {
@@ -90,11 +90,7 @@ namespace Map {
// canviarà el flag a true quan transiti a OPEN.
door_open = false;
std::cout << "Map: geometria " << grid_cols << "x" << grid_rows
<< " sarcs " << sarc_w << "x" << sarc_h
<< " pitch " << pitch_x << "x" << pitch_y
<< " door_col " << door_col
<< " des de " << yaml_path << '\n';
Log::info("Map", "geometria {}x{} sarcs {}x{} pitch {}x{} door_col {} des de {}", grid_cols, grid_rows, sarc_w, sarc_h, pitch_x, pitch_y, door_col, yaml_path);
}
auto getSarcRect(int col, int row) -> SDL_FRect {
+2 -4
View File
@@ -3,7 +3,6 @@
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <iostream>
#include <variant>
#include "core/rendering/screen.hpp"
@@ -11,6 +10,7 @@
#include "core/resources/resource_cache.hpp"
#include "game/map/map.hpp"
#include "game/map/tilemap.hpp"
#include "utils/log.hpp"
// CONTRACTE BUS DE PINTAT
// -----------------------
@@ -89,9 +89,7 @@ PaintLayer::PaintLayer(const Tilemap& tilemap, EventBus* bus)
});
}
std::cout << "PaintLayer: " << cols_ << "x" << rows_ << " tiles ("
<< width_px_ << "x" << height_px_ << " px), tileset "
<< tileset_path_ << '\n';
Log::info("PaintLayer", "{}x{} tiles ({}x{} px), tileset {}", cols_, rows_, width_px_, height_px_, tileset_path_);
}
PaintLayer::~PaintLayer() {
+2 -3
View File
@@ -3,7 +3,6 @@
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <iostream>
#include <stdexcept>
#include <vector>
@@ -12,6 +11,7 @@
#include "core/resources/resource_cache.hpp"
#include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp"
#include "utils/log.hpp"
#ifdef _DEBUG
#include "game/map/tilemap_writer.hpp"
@@ -97,8 +97,7 @@ Tilemap::Tilemap(const std::string& yaml_path) {
}
}
std::cout << "Tilemap: " << cols_ << "x" << rows_ << " tiles ("
<< width_px_ << "x" << height_px_ << " px) des de " << yaml_path << '\n';
Log::info("Tilemap", "{}x{} tiles ({}x{} px) des de {}", cols_, rows_, width_px_, height_px_, yaml_path);
}
#ifdef _DEBUG
+14 -15
View File
@@ -3,7 +3,6 @@
#include <algorithm>
#include <array>
#include <fstream>
#include <iostream>
#include <sstream>
#include <string>
#include <string_view>
@@ -12,6 +11,7 @@
#include "core/input/gamepad_manager.hpp"
#include "external/fkyaml_node.hpp"
#include "utils/defines.hpp"
#include "utils/log.hpp"
namespace {
@@ -104,7 +104,7 @@ namespace {
std::ofstream dst(path + ".bak", std::ios::binary);
if (!dst) { return; }
dst << src.rdbuf();
std::cout << "Options: còpia de seguretat a " << path << ".bak\n";
Log::info("Options", "còpia de seguretat a {}.bak", path);
}
void parseWindow(const fkyaml::node& yaml) {
@@ -463,7 +463,7 @@ namespace {
try {
auto yaml = fkyaml::node::deserialize(CONTENT);
if (!yaml.contains("presets") || !yaml["presets"].is_sequence()) {
std::cout << "Options: " << kind_label << " manca `presets:`; reescrivint amb defaults.\n";
Log::warn("Options", "{} manca `presets:`; reescrivint amb defaults.", kind_label);
backupFile(path);
presets = defaults_fn();
return save_fn();
@@ -475,15 +475,15 @@ namespace {
presets.push_back(preset);
}
if (presets.empty()) {
std::cout << "Options: " << kind_label << " sense presets; reescrivint amb defaults.\n";
Log::warn("Options", "{} sense presets; reescrivint amb defaults.", kind_label);
backupFile(path);
presets = defaults_fn();
return save_fn();
}
return true;
} catch (const std::exception& e) {
std::cout << "Options: error parsejant " << kind_label << ": " << e.what() << '\n';
std::cout << "Options: reescrivint el fitxer amb presets per defecte.\n";
Log::error("Options", "error parsejant {}: {}", kind_label, e.what());
Log::info("Options", "reescrivint el fitxer amb presets per defecte.");
backupFile(path);
presets = defaults_fn();
return save_fn();
@@ -655,8 +655,7 @@ namespace Options {
// amb els defaults de la versió nova. El fitxer original queda
// a `<path>.bak` per si l'usuari volia preservar tuning manual.
if (file_version != Texts::VERSION) {
std::cout << "Options: config.yaml versió \"" << file_version
<< "\" != \"" << Texts::VERSION << "\"; reescrivint amb defaults.\n";
Log::warn("Options", "config.yaml versió \"{}\" != \"{}\"; reescrivint amb defaults.", file_version, Texts::VERSION);
backupFile(g_config_file_path);
return saveToFile();
}
@@ -679,8 +678,8 @@ namespace Options {
if (CHANGED) { saveToFile(); }
return true;
} catch (const std::exception& e) {
std::cout << "Options: error parsejant config.yaml: " << e.what() << '\n';
std::cout << "Options: reescrivint el fitxer amb defaults.\n";
Log::error("Options", "error parsejant config.yaml: {}", e.what());
Log::info("Options", "reescrivint el fitxer amb defaults.");
backupFile(g_config_file_path);
return saveToFile();
}
@@ -692,7 +691,7 @@ namespace Options {
g_config_version = Texts::VERSION;
std::ostringstream out;
out << "# Configuració d'AEE 2026. Generat automàticament; editable a mà.\n";
out << "# Configuració d'AEE ARCADE. Generat automàticament; editable a mà.\n";
out << "version: \"" << g_config_version << "\"\n";
out << "locale: \"" << g_locale << "\"\n\n";
@@ -780,7 +779,7 @@ namespace Options {
g_postfx_file_path,
g_postfx_presets,
&writePostFXPreset,
"Presets PostFX per a AEE 2026. Editable a mà.",
"Presets PostFX per a AEE ARCADE. Editable a mà.",
"Els noms han de coincidir amb config.yaml > video.shader.current_postfx_preset.");
}
@@ -803,7 +802,7 @@ namespace Options {
g_crtpi_file_path,
g_crtpi_presets,
&writeCrtPiPreset,
"Presets CRT-Pi per a AEE 2026. Editable a mà.",
"Presets CRT-Pi per a AEE ARCADE. Editable a mà.",
"Els noms han de coincidir amb config.yaml > video.shader.current_crtpi_preset.");
}
@@ -843,7 +842,7 @@ namespace Options {
const auto IT = std::ranges::find_if(g_crtpi_presets,
[](const CrtPiPreset& preset) { return preset.name == g_video.shader.current_crtpi_preset; });
if (IT == g_crtpi_presets.end()) {
std::cout << "Options: preset CrtPi \"" << g_video.shader.current_crtpi_preset << "\" no trobat; aplicant defaults.\n";
Log::warn("Options", "preset CrtPi \"{}\" no trobat; aplicant defaults.", g_video.shader.current_crtpi_preset);
return {};
}
return {
@@ -871,7 +870,7 @@ namespace Options {
const auto IT = std::ranges::find_if(g_postfx_presets,
[](const PostFXPreset& preset) { return preset.name == g_video.shader.current_postfx_preset; });
if (IT == g_postfx_presets.end()) {
std::cout << "Options: preset PostFX \"" << g_video.shader.current_postfx_preset << "\" no trobat; aplicant passthrough.\n";
Log::warn("Options", "preset PostFX \"{}\" no trobat; aplicant passthrough.", g_video.shader.current_postfx_preset);
return {};
}
return {
+167
View File
@@ -0,0 +1,167 @@
#include "game/scenes/game_over_fsm.hpp"
#include <algorithm>
#include <array>
#include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp"
#include "utils/log.hpp"
namespace {
// Llegeix un sub-camp sencer d'un node YAML. Si manca, retorna el
// fallback. Mateix patró que `RoomFsm::loadConfig`.
auto readChildInt(const fkyaml::node& parent, const char* key, int fallback) -> int {
if (!parent.contains(key)) { return fallback; }
return parent[key].get_value<int>();
}
} // namespace
namespace GameOverFsm {
auto loadConfig(const std::string& yaml_path) -> Config {
Config cfg{};
auto bytes = Resource::Helper::loadFile(yaml_path);
if (bytes.empty()) {
Log::warn("GameOverFsm", "{} absent — uso fallbacks compilats", yaml_path);
return cfg;
}
const auto* begin = reinterpret_cast<const char*>(bytes.data());
const auto* end = begin + bytes.size();
auto yaml = fkyaml::node::deserialize(begin, end);
if (yaml.contains("game_over")) {
const auto& n = yaml["game_over"];
cfg.continue_text_enter_ms = readChildInt(n, "continue_text_enter_ms", cfg.continue_text_enter_ms);
cfg.continue_text_hold_ms = readChildInt(n, "continue_text_hold_ms", cfg.continue_text_hold_ms);
cfg.continue_text_exit_ms = readChildInt(n, "continue_text_exit_ms", cfg.continue_text_exit_ms);
cfg.continue_count_start = readChildInt(n, "continue_count_start", cfg.continue_count_start);
cfg.game_over_text_hold_ms = readChildInt(n, "game_over_text_hold_ms", cfg.game_over_text_hold_ms);
cfg.game_over_text_exit_ms = readChildInt(n, "game_over_text_exit_ms", cfg.game_over_text_exit_ms);
cfg.game_over_wait_ms = readChildInt(n, "game_over_wait_ms", cfg.game_over_wait_ms);
cfg.fade_out_ms = readChildInt(n, "fade_out_ms", cfg.fade_out_ms);
cfg.black_hold_ms = readChildInt(n, "black_hold_ms", cfg.black_hold_ms);
if (n.contains("continue_music_speed")) {
cfg.continue_music_speed = n["continue_music_speed"].get_value<float>();
}
if (n.contains("game_over_music_track")) {
cfg.game_over_music_track = n["game_over_music_track"].get_value<std::string>();
}
if (n.contains("countdown_sound_key")) {
cfg.countdown_sound_key = n["countdown_sound_key"].get_value<std::string>();
}
cfg.skip_below_or_equal = readChildInt(n, "skip_below_or_equal", cfg.skip_below_or_equal);
}
Log::info("GameOverFsm", "timings carregats des de {} (continue hold {}ms, fade {}ms)", yaml_path, cfg.continue_text_hold_ms, cfg.fade_out_ms);
return cfg;
}
auto advance(State current, Transition& t, const Config& cfg, int delta_ms) -> State {
if (current == State::OFF || current == State::DONE) { return current; }
t.elapsed_ms += delta_ms;
if (current == State::CONTINUE_PROMPT) {
const std::array<int, 3> DURS = {
cfg.continue_text_enter_ms,
cfg.continue_text_hold_ms,
cfg.continue_text_exit_ms,
};
while (t.phase < static_cast<int>(DURS.size()) && t.elapsed_ms >= DURS[t.phase]) {
t.elapsed_ms -= DURS[t.phase];
// Si acabem la fase 1 (hold) sense cancel·lació, saltem
// directament a GAME_OVER — el panel queda amunt i no
// es reprodueix l'exit anim. La fase 2 (exit) només
// s'executa si el GameScene l'ha forçada amb
// `cancelContinuePrompt`.
if (t.phase == 1) {
return State::GAME_OVER;
}
++t.phase;
}
if (t.phase >= static_cast<int>(DURS.size())) { return State::OFF; }
return current;
}
if (current == State::GAME_OVER) {
const std::array<int, 3> DURS = {
cfg.game_over_text_hold_ms,
cfg.game_over_text_exit_ms,
cfg.game_over_wait_ms,
};
while (t.phase < static_cast<int>(DURS.size()) && t.elapsed_ms >= DURS[t.phase]) {
t.elapsed_ms -= DURS[t.phase];
++t.phase;
}
if (t.phase >= static_cast<int>(DURS.size())) { return State::FADE_OUT; }
return current;
}
// FADE_OUT
const std::array<int, 2> DURS = {
cfg.fade_out_ms,
cfg.black_hold_ms,
};
while (t.phase < static_cast<int>(DURS.size()) && t.elapsed_ms >= DURS[t.phase]) {
t.elapsed_ms -= DURS[t.phase];
++t.phase;
}
if (t.phase >= static_cast<int>(DURS.size())) { return State::DONE; }
return current;
}
void cancelContinuePrompt(State current, Transition& t) {
if (current != State::CONTINUE_PROMPT) { return; }
if (t.phase >= 2) { return; } // ja en exit anim, no-op
t.phase = 2;
t.elapsed_ms = 0;
}
void skipToNextCountdown(State current, Transition& t, const Config& cfg) {
if (current != State::CONTINUE_PROMPT) { return; }
if (t.phase != 1) { return; }
const int START = std::max(0, cfg.continue_count_start);
const int STEP_COUNT = START + 1;
const int STEP_MS = std::max(1, cfg.continue_text_hold_ms / STEP_COUNT);
const int CONSUMED = t.elapsed_ms / STEP_MS;
const int CURRENT = std::clamp(START - CONSUMED, 0, START);
// No saltem si ja som al 0: el següent "número" és la transició
// a GAME_OVER, que `advance` ja gestiona quan el hold s'esgota.
if (CURRENT <= 0) { return; }
t.elapsed_ms = (CONSUMED + 1) * STEP_MS;
}
auto countdownSecondsRemaining(State current, const Transition& t, const Config& cfg) -> int {
if (current != State::CONTINUE_PROMPT) { return 0; }
if (t.phase != 1) { return 0; }
// La sèrie és [start, start-1, ..., 1, 0]: en total `start+1`
// graons. El primer número visible és `start` i l'últim 0 es
// manté durant el seu graó abans que la fase 1 acabi i la FSM
// salti a GAME_OVER. Si `start == 0` només es mostra "0"
// durant tot el hold.
const int START = std::max(0, cfg.continue_count_start);
const int STEP_COUNT = START + 1;
const int STEP_MS = std::max(1, cfg.continue_text_hold_ms / STEP_COUNT);
const int CONSUMED = t.elapsed_ms / STEP_MS;
return std::clamp(START - CONSUMED, 0, START);
}
auto stateName(State s) -> const char* {
switch (s) {
case State::OFF:
return "OFF";
case State::CONTINUE_PROMPT:
return "CONTINUE_PROMPT";
case State::GAME_OVER:
return "GAME_OVER";
case State::FADE_OUT:
return "FADE_OUT";
case State::DONE:
return "DONE";
}
return "?";
}
} // namespace GameOverFsm
+150
View File
@@ -0,0 +1,150 @@
#pragma once
#include <cstdint>
#include <string>
// FSM secundària de la GameScene que governa el flux "tots els jugadors
// fora de partida" — CONTINUE? amb countdown → GAME OVER → fade-to-black
// → transició a LOGO. Viu separada de la `RoomFsm` (que gestiona
// intro/outro de cada sala) perquè els seus disparadors són ortogonals
// i la composició entre les dues queda al GameScene.
//
// Lògica pura: no toca SDL ni Audio, només temporitzadors. La part
// visual (text + panel + fade) viu al GameScene reaprofitant la mateixa
// infraestructura del room text.
namespace GameOverFsm {
// Estats canònics. Phase indices per estat:
// OFF — joc normal; el GameScene transita a
// CONTINUE_PROMPT quan detecta 0 jugadors actius.
// CONTINUE_PROMPT — phases: 0=text_enter, 1=text_hold (countdown
// visible), 2=text_exit (només si l'usuari
// cancel·la pulsant START). Si la fase 1 acaba
// sense cancel·lació → salt a GAME_OVER amb el
// panel encara amunt (sense exit anim).
// GAME_OVER — phases: 0=text_hold (apareix sense enter
// anim, panel ja amunt), 1=text_exit, 2=wait
// (otro tiempo más abans del fade).
// FADE_OUT — phases: 0=fade, 1=black_hold (perquè el
// negre quedi un instant abans de cedir el
// pas a la LogoScene).
// DONE — sentinel: el caller (GameScene) ha de
// demanar la transició cap a LOGO.
enum class State : std::uint8_t {
OFF,
CONTINUE_PROMPT,
GAME_OVER,
FADE_OUT,
DONE,
};
// Sub-fase i temps acumulat dins de l'estat actual. Es reseteja a
// {0, 0} quan es canvia d'estat (`GameScene::setGameOverState`).
struct Transition {
int phase{0};
int elapsed_ms{0};
};
// Timings llegits de `data/scenes/game/game.yaml` (secció
// `game_over:`). Si el fitxer no porta cap d'aquests camps, els
// valors per defecte coincideixen amb els del .yaml versionat.
struct Config {
// CONTINUE? — animació d'entrada/sortida idèntica al room text.
int continue_text_enter_ms{500};
// Hold = duració total del countdown. Es divideix en
// (continue_count_start + 1) graons iguals; el cas canònic és
// 10000 ms / 10 graons d'1 s cadascun.
int continue_text_hold_ms{10000};
int continue_text_exit_ms{500};
// Primer número visible al countdown. La sèrie és
// [continue_count_start, ..., 1, 0] — sempre acaba en 0 i el
// valor 0 es manté visible durant l'últim graó abans del salt
// a GAME_OVER. 9 → "9, 8, ..., 0" (10 graons).
int continue_count_start{9};
// GAME OVER — apareix sense enter anim (el panel ja és amunt
// del CONTINUE?) i s'esvaeix amb la mateixa exit anim que el
// room text. `wait_ms` és el "otro tiempo más" abans del fade.
int game_over_text_hold_ms{2000};
int game_over_text_exit_ms{500};
int game_over_wait_ms{500};
// Fade final cap a LOGO. `black_hold_ms` és el temps amb el
// canvas negre opac abans que el GameScene demani la transició.
int fade_out_ms{500};
int black_hold_ms{500};
// Multiplicador de velocitat de la música mentre dura el
// CONTINUE_PROMPT. 1.0 = sense canvi, >1 = més ràpid (i amb el
// to més agut: és l'efecte arcade clàssic dels comptes enrere).
// Es restableix a 1.0 al sortir del prompt (revive o GAME_OVER).
float continue_music_speed{1.5F};
// Pista de música que sona durant l'estat GAME_OVER (sense loop).
// Si la pista no està carregada al Resource::Cache, s'omet la
// reproducció i la fase manté els timings configurats. La durada
// de la pista determina implícitament `game_over_text_hold_ms`
// (vegeu `GameScene::setGameOverState`).
std::string game_over_music_track{"game_over.ogg"};
// So que sona cada vegada que apareix un nou número del
// countdown (al primer cop que es mostra el `start`, cada
// canvi de número, i quan apareix el 0). NO sona en la
// transició posterior cap a GAME_OVER. Buit = sense so.
std::string countdown_sound_key{"countdown.wav"};
// Llindar a partir del qual qualsevol botó de salt o atac (de
// qualsevol jugador) salta al següent número del countdown.
// Exemple: 7 → mentre el número visible és 7, 6, 5, ..., 1
// (no a 0, que ja no té "següent número") un botó d'acció
// avança al següent. Si el valor és més gran que `start` el
// skip queda desactivat efectivament. -1 = mai.
int skip_below_or_equal{7};
};
// Llegeix la configuració des del YAML. Si el fitxer manca o falta
// alguna clau, els camps no presents conserven els seus fallbacks.
[[nodiscard]] auto loadConfig(const std::string& yaml_path) -> Config;
// Avança la transició consumint `delta_ms` des del temps real.
// Retorna l'estat resultant. Si retorna un estat diferent, el
// caller ha de resetejar la `Transition` a {0, 0} abans del següent
// frame. Funció pura sobre l'argument.
//
// Particularitats:
// - CONTINUE_PROMPT phase 1 (hold) arribar a la fi → GAME_OVER
// (NO reprodueix exit anim; el caller ja té el panel amunt).
// - CONTINUE_PROMPT phase 2 (exit) arribar a la fi → OFF (cas
// "cancel·lació via START": el GameScene ha forçat la fase 2
// amb `cancelContinuePrompt`).
// - GAME_OVER phase 2 (wait) arribar a la fi → FADE_OUT.
// - FADE_OUT phase 1 (black_hold) arribar a la fi → DONE.
// - DONE i OFF no avancen ni canvien d'estat (sumiders).
[[nodiscard]] auto advance(State current, Transition& t, const Config& cfg, int delta_ms) -> State;
// Salta a la fase 2 (text_exit) del CONTINUE_PROMPT. No fa res si
// l'estat no és CONTINUE_PROMPT o ja som a la fase 2 (idempotent).
// Disparat pel GameScene quan un jugador pulsa START dins del
// countdown — el join real també el fa GameScene; aquesta funció
// només dispara la sortida visual del rètol.
void cancelContinuePrompt(State current, Transition& t);
// Quants segons "queden" del countdown de CONTINUE?. Retorna un
// enter en el rang [0, continue_count_steps] perquè es renderitzi
// com a segona línia del rètol durant la fase 1 (hold). Fora de
// CONTINUE_PROMPT phase 1, retorna 0 (no s'hauria de pintar).
[[nodiscard]] auto countdownSecondsRemaining(State current, const Transition& t, const Config& cfg) -> int;
// Salta al següent número del countdown (decrementa el valor
// visible en una unitat). Només actua dins de CONTINUE_PROMPT
// phase 1 i quan el valor actual és > 0 — al 0 ja no hi ha
// "següent número", la fase ha d'esgotar-se de manera natural i
// el caller es recolza en `advance` per al salt a GAME_OVER. La
// GameScene comprova també el llindar `skip_below_or_equal`
// abans de cridar; aquesta funció, per simplicitat, no en sap.
void skipToNextCountdown(State current, Transition& t, const Config& cfg);
// Nom textual per a logs i tests.
[[nodiscard]] auto stateName(State s) -> const char*;
} // namespace GameOverFsm
+483 -64
View File
@@ -7,7 +7,6 @@
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <iostream>
#include <iterator>
#include <utility>
#include <variant>
@@ -33,14 +32,18 @@
#include "game/entities/entity_loader.hpp"
#include "game/entities/event_bus.hpp"
#include "game/entities/player.hpp"
#include "game/gameplay_config.hpp"
#include "game/levels/level_manager.hpp"
#include "game/map/map.hpp"
#include "game/options.hpp"
#include "game/scenes/scene_flow.hpp"
#include "game/scenes/start_input.hpp"
#include "game/ui/hud.hpp"
#include "game/ui/service_menu.hpp"
#include "utils/defaults.hpp"
#include "utils/defines.hpp"
#include "utils/easing.hpp"
#include "utils/log.hpp"
namespace {
@@ -108,6 +111,11 @@ namespace {
auto* a = ps[0];
auto* b = ps[1];
if (a == nullptr || b == nullptr) { return; }
// Tweak `players.collide_with_each_other`: amb el flag desactivat
// saltem TOT el resolver — head-on, push i bounce aeri queden
// anul·lats. La col·lisió contra el món continua intacta perquè
// viu a `Player::collidesWithWorld`/`tryMove`, no aquí.
if (!GameplayConfig::get().playersCollide()) { return; }
const bool A_AIR = a->isAirborne();
const bool B_AIR = b->isAirborne();
@@ -229,10 +237,10 @@ auto GameScene::defaultConfig() -> Config {
// concret de sala — només cap a on apunta la progressió ara mateix.
return {
.map_yaml = Levels::LevelManager::get().currentPath(),
.player_sprite = Defaults::Pyramid::PLAYER_SPRITE,
.player_yaml = Defaults::Pyramid::PLAYER_YAML,
.player2_sprite = Defaults::Pyramid::PLAYER_SPRITE_P2,
.player2_yaml = Defaults::Pyramid::PLAYER_YAML_P2,
.player_sprite = Defaults::Pyramid::PLAYER_SAM_GIF,
.player_yaml = Defaults::Pyramid::PLAYER_SAM_YAML,
.player2_sprite = Defaults::Pyramid::PLAYER_MORCUS_GIF,
.player2_yaml = Defaults::Pyramid::PLAYER_MORCUS_YAML,
.music_track = Defaults::Pyramid::MUSIC_TRACK,
.starting_players = DebugConfig::StartingPlayers::BOTH,
};
@@ -262,7 +270,7 @@ GameScene::GameScene(Config config)
const bool USE_P1 = (config_.starting_players == DebugConfig::StartingPlayers::P1 || config_.starting_players == DebugConfig::StartingPlayers::BOTH);
const bool USE_P2 = (config_.starting_players == DebugConfig::StartingPlayers::P2 || config_.starting_players == DebugConfig::StartingPlayers::BOTH);
players_starts_active_ = {USE_P1, USE_P2};
players_active_ = {USE_P1, USE_P2};
// Carreguem assets per als DOS jugadors sempre, encara que algun no
// arranque actiu: l'slot existeix igualment al vector com a DISABLED
@@ -290,13 +298,12 @@ GameScene::GameScene(Config config)
// re-emissions es filtren per estat per no re-entrar mai des d'una
// transició ja en marxa.
entity_manager_.getBus().subscribe("level_complete", [this](const Event& ev) {
std::cout << "[LEVEL COMPLETE] player_id=" << ev.player_id << '\n';
Log::info("LEVEL COMPLETE", "player_id={}", ev.player_id);
if (state_ == RoomFsm::State::PLAYING) { setState(RoomFsm::State::ROOM_OUTRO); }
});
entity_manager_.getBus().subscribe("player_hit", [](const Event& ev) {
const auto* dmg = std::get_if<int>(&ev.payload);
std::cout << "[PLAYER HIT] player_id=" << ev.player_id
<< " dmg=" << (dmg != nullptr ? *dmg : 0) << '\n';
Log::info("PLAYER HIT", "player_id={} dmg={}", ev.player_id, dmg != nullptr ? *dmg : 0);
});
// `player_killed` el publica un hazard letal (mòmia amb
// `kills_outright`) després del `player_hit`. Aquí resolem el
@@ -306,13 +313,20 @@ GameScene::GameScene(Config config)
entity_manager_.getBus().subscribe("player_killed", [this](const Event& ev) {
dispatchPlayerKilled(ev.player_id);
});
// `player_disabled` el publica el Player al final del cicle DYING
// quan `lives_remaining_` arriba a 0. La feina viu a
// `onPlayerDisabled` per evitar pujar la complexitat del ctor; el
// HUD ja s'apunta al mateix event per banda.
entity_manager_.getBus().subscribe("player_disabled", [this](const Event& ev) {
onPlayerDisabled(ev.player_id);
});
// Polvet: precarreguem el surface del Dust una sola vegada perquè
// els spawns runtime de l'entitat no facin disc al primer salt.
// També ens subscrivim a `player_jumped` i `player_landed` per
// spawnejar el polvet de despegue / aterratge a la posició dels
// peus que el Player ens dóna al payload.
Resource::Cache::get().loadSurface("dust.gif", "data/entities/dust/dust.gif");
Resource::Cache::get().loadSurface("dust.gif", Defaults::Paths::DUST_GIF);
entity_manager_.getBus().subscribe("player_jumped", [this](const Event& ev) {
const auto* pos = std::get_if<WorldPos>(&ev.payload);
if (pos != nullptr) { spawnDust(pos->x, pos->y, false); }
@@ -325,10 +339,10 @@ GameScene::GameScene(Config config)
// Whip: precarreguem les 4 surfaces (una per cardinal) abans del
// primer spawn perquè el Player no facte disc al primer atac.
// Subscriu `whip_spawn` per crear el `WhipAttack` corresponent.
Resource::Cache::get().loadSurface("whip_east.gif", "data/entities/whip_attack/whip_east.gif");
Resource::Cache::get().loadSurface("whip_west.gif", "data/entities/whip_attack/whip_west.gif");
Resource::Cache::get().loadSurface("whip_north.gif", "data/entities/whip_attack/whip_north.gif");
Resource::Cache::get().loadSurface("whip_south.gif", "data/entities/whip_attack/whip_south.gif");
Resource::Cache::get().loadSurface("whip_east.gif", Defaults::Paths::WHIP_EAST_GIF);
Resource::Cache::get().loadSurface("whip_west.gif", Defaults::Paths::WHIP_WEST_GIF);
Resource::Cache::get().loadSurface("whip_north.gif", Defaults::Paths::WHIP_NORTH_GIF);
Resource::Cache::get().loadSurface("whip_south.gif", Defaults::Paths::WHIP_SOUTH_GIF);
entity_manager_.getBus().subscribe("whip_spawn", [this](const Event& ev) {
const auto* p = std::get_if<WhipSpawnPayload>(&ev.payload);
if (p != nullptr) { spawnWhip(*p); }
@@ -428,7 +442,7 @@ GameScene::GameScene(Config config)
// tots els jugadors *actius* han travessat la porta. Els slots DISABLED
// permanents (single-player) NO compten — d'altra manera la sala mai
// es completaria, perquè un slot DISABLED no pot eixir per la porta.
const int ACTIVE_COUNT = std::ranges::count(players_starts_active_, true);
const int ACTIVE_COUNT = std::ranges::count(players_active_, true);
entity_manager_.setExpectedPlayerCount(ACTIVE_COUNT);
// El snap també empeny lateralment si l'altre jugador ocupa el carril:
// passem el mateix factor perquè vaja al mateix ritme que el resolver.
@@ -442,10 +456,17 @@ GameScene::GameScene(Config config)
auto hud_font_surface = Resource::Cache::get().getSurface(HUD_FONT_GIF_KEY);
auto hud_font = std::make_shared<Text>(hud_font_surface, UiConfig::get().hudFont().fnt);
hud_font->setDefaultKerning(UiConfig::get().hudFont().kerning);
hud_ = std::make_unique<Hud>(entity_manager_.getBus(), hud_font);
// Si hi ha P2 actiu, activem el seu bloc (per defecte el HUD naix amb
// només P1). Mentre els events del bus no porten `player_id`, els hits
// segueixen aplicant-se a P1 (veure TODO a Hud::onPlayerHit).
// Vides inicials del HUD = `initial_lives` del YAML del jugador
// (`Player::loadFrom` ja ha omplert `death_config` abans d'aquí).
// Sense això, el marcador es queda fixe a 3 i el YAML perd efecte.
hud_ = std::make_unique<Hud>(entity_manager_.getBus(), hud_font, Player::getInitialLives());
// Activem el bloc HUD per als slots que arranquen en partida. Els
// inactius queden mostrant el prompt "POLSA START / PER JUGAR" via
// `refreshJoinPrompt` cada frame. Tots dos s'haurien de poder
// activar (cas BOTH) o un dels dos (cas P1 o P2 sols) — el HUD
// naix amb tots dos slots inactius i és aquesta crida la que dóna
// l'estat real.
if (USE_P1) { hud_->activatePlayer(1); }
if (USE_P2) { hud_->activatePlayer(2); }
// Sincronització inicial dels comptadors de piràmide/sala del HUD amb
// el LevelManager (després de cada `loadRoom` també es refresca; ací
@@ -479,7 +500,13 @@ GameScene::GameScene(Config config)
// Timings i corbes de la FSM de sala. Llegits del YAML una sola
// vegada; canvis requereixen reiniciar el joc.
transition_config_ = RoomFsm::loadConfig(Defaults::GameScene::CONFIG_YAML);
transition_config_ = RoomFsm::loadConfig(Defaults::GameScene::GAME_YAML);
// Timings de la sub-FSM de game over (CONTINUE? + GAME OVER + fade
// final). Comparteix YAML amb la RoomFsm però viu en una secció
// pròpia (`game_over:`).
game_over_config_ = GameOverFsm::loadConfig(Defaults::GameScene::GAME_YAML);
preloadGameOverAssets();
// Cinemàtica d'entrada de la primera sala: arrenca la pista
// `room_intro.ogg` i arma el callback de fi que farà spawn + salt
@@ -489,7 +516,18 @@ GameScene::GameScene(Config config)
enterRoomIntro();
}
GameScene::~GameScene() = default;
GameScene::~GameScene() {
// Aturem qualsevol música que estigués sonant al sortir de la
// GameScene. Això cobreix tres camins: (a) transició natural a
// LOGO al final del game over (l'única audio activa pot ser la
// pista `game_over.ogg`, que en aquell moment ja s'ha drenat);
// (b) tancament directe via service menu o ALT+F4; (c) qualsevol
// futura sortida no-natural. La LogoScene té la seva pròpia
// música i no s'ha de mesclar amb la del joc.
if (auto* audio = Audio::get(); audio != nullptr) {
audio->stopMusic();
}
}
auto GameScene::computeSpawn() -> PlayerSpawn {
// Centre de la columna del passadís en píxels canvas. La caixa de
@@ -537,7 +575,7 @@ void GameScene::loadRoom(const std::string& yaml_path) {
for (auto& p : players_) { p->setMapInfo(INFO); }
// Tornem a fixar el comptador de "esperats" amb només els slots
// actius — un slot DISABLED permanent (single-player) no compta.
const int ACTIVE_COUNT = std::ranges::count(players_starts_active_, true);
const int ACTIVE_COUNT = std::ranges::count(players_active_, true);
entity_manager_.setExpectedPlayerCount(ACTIVE_COUNT);
// Tornem tots els slots a DISABLED. Els actius els reactivarà el
@@ -556,8 +594,7 @@ void GameScene::loadRoom(const std::string& yaml_path) {
hud_->setPyramidRoom(mgr.currentPyramid(), mgr.currentRoom());
}
std::cout << "[ROOM] load " << yaml_path << ": entities reloaded, paint cleared, "
<< ACTIVE_COUNT << " active player(s) disabled (awaiting spawn cinematic)\n";
Log::info("ROOM", "load {}: entities reloaded, paint cleared, {} active player(s) disabled (awaiting spawn cinematic)", yaml_path, ACTIVE_COUNT);
}
void GameScene::advanceToNextRoom() {
@@ -565,7 +602,7 @@ void GameScene::advanceToNextRoom() {
if (!mgr.advance()) {
// Política temporal mentre no hi haja pantalla de "Fi de joc":
// arribar al final del levels.yaml fa loop a la primera sala.
std::cout << "[ROOM] final del levels.yaml — bucle a la primera sala\n";
Log::info("ROOM", "final del levels.yaml — bucle a la primera sala");
mgr.restart();
}
loadRoom(mgr.currentPath());
@@ -576,9 +613,9 @@ void GameScene::enterRoomIntro() {
// Anotem la transició al log si veníem d'un altre estat; si ja
// estàvem a INTRO (cas típic d'un salt debug) també queda ben clar.
if (state_ != RoomFsm::State::ROOM_INTRO) {
std::cout << "[ROOM] " << RoomFsm::stateName(state_) << " -> ROOM_INTRO\n";
Log::info("ROOM", "{} -> ROOM_INTRO", RoomFsm::stateName(state_));
} else {
std::cout << "[ROOM] ROOM_INTRO restart (cinemàtica reiniciada)\n";
Log::info("ROOM", "ROOM_INTRO restart (cinemàtica reiniciada)");
}
state_ = RoomFsm::State::ROOM_INTRO;
transition_ = {};
@@ -602,7 +639,7 @@ void GameScene::enterRoomIntro() {
auto* audio = Audio::get();
if (audio == nullptr) {
std::cout << "[ROOM] INTRO: Audio absent — sense música, timeline només contra text\n";
Log::warn("ROOM", "INTRO: Audio absent — sense música, timeline només contra text");
return;
}
audio->stopMusic();
@@ -611,10 +648,9 @@ void GameScene::enterRoomIntro() {
if (Audio::getMusicState() == Audio::MusicState::PLAYING) {
const int DUR_MS = Audio::getMusicDurationMs("room_intro.ogg");
transition_config_.intro_music_tail_ms = std::max(0, DUR_MS - TEXT_TOTAL_MS);
std::cout << "[ROOM] INTRO: música " << DUR_MS << " ms, music_tail "
<< transition_config_.intro_music_tail_ms << " ms\n";
Log::info("ROOM", "INTRO: música {} ms, music_tail {} ms", DUR_MS, transition_config_.intro_music_tail_ms);
} else {
std::cout << "[ROOM] INTRO: música no arrencada — timeline només contra text\n";
Log::warn("ROOM", "INTRO: música no arrencada — timeline només contra text");
}
}
@@ -626,10 +662,10 @@ void GameScene::respawnPlayersInstant() {
// mateix patró de `triggerPlayerSpawn` però cridem `respawn(x, y)`
// (teleport instantani) en lloc de `spawnFromAir(x, y)`.
const auto SPAWN = computeSpawn();
const bool BOTH_ACTIVE = (players_starts_active_[0] && players_starts_active_[1]);
const bool BOTH_ACTIVE = (players_active_[0] && players_active_[1]);
const float SINGLE_X = SPAWN.p1_x + static_cast<float>(Map::tile_size);
for (std::size_t i = 0; i < players_.size(); ++i) {
if (!players_starts_active_[i]) { continue; }
if (!players_active_[i]) { continue; }
float x = SINGLE_X;
if (BOTH_ACTIVE) { x = (i == 0) ? SPAWN.p1_x : SPAWN.p2_x; }
players_[i]->setSpawnPoint(x, SPAWN.y);
@@ -638,20 +674,20 @@ void GameScene::respawnPlayersInstant() {
state_ = RoomFsm::State::PLAYING;
intro_completed_ = true;
transition_ = {};
std::cout << "[ROOM] post-editor: spawn instantani, estat -> PLAYING\n";
Log::info("ROOM", "post-editor: spawn instantani, estat -> PLAYING");
}
#endif
void GameScene::triggerPlayerSpawn(std::size_t slot_index) {
if (slot_index >= players_.size()) { return; }
if (!players_starts_active_[slot_index]) { return; }
if (!players_active_[slot_index]) { return; }
if (players_spawned_in_intro_[slot_index]) { return; }
players_spawned_in_intro_[slot_index] = true;
// Recalcula spawn per si door_col canvia entre sales (avui no, però
// la lògica ja queda preparada per a l'itinerari).
const auto SPAWN = computeSpawn();
const bool BOTH_ACTIVE = (players_starts_active_[0] && players_starts_active_[1]);
const bool BOTH_ACTIVE = (players_active_[0] && players_active_[1]);
// Mode single-player: l'únic actiu queda centrat sota la porta. Els
// valors de SPAWN.p1_x/p2_x estan fets per al cas BOTH (offset ±tile);
// amb un sol actiu, el reposicionem al centre exacte.
@@ -671,17 +707,114 @@ void GameScene::triggerPlayerSpawn(std::size_t slot_index) {
// al YAML, l'arma queda buida i el LAND és sec igualment.
players_[slot_index]->armNextLandReverb(transition_config_.intro_land_reverb_preset);
std::cout << "[ROOM] spawn cinematic P" << (slot_index + 1) << ": airborne entry\n";
Log::info("ROOM", "spawn cinematic P{}: airborne entry", slot_index + 1);
}
void GameScene::dispatchPlayerKilled(int player_id) {
std::cout << "[PLAYER KILLED] player_id=" << player_id << '\n';
Log::info("PLAYER KILLED", "player_id={}", player_id);
const auto IT = std::ranges::find_if(players_, [player_id](const auto& p) {
return p && p->getId() == player_id;
});
if (IT != players_.end()) { (*IT)->enterDying(); }
}
void GameScene::onPlayerDisabled(int player_id) {
Log::info("PLAYER DISABLED", "player_id={}", player_id);
if (player_id < 0 || player_id >= static_cast<int>(players_active_.size())) { return; }
players_active_[player_id] = false;
}
void GameScene::preloadGameOverAssets() const {
// Pre-load del so del countdown: el dispara `updateGameOverFlow`
// cada vegada que canvia el número visible de CONTINUE? (incloent
// l'aparició del 0 — però NO la transició a GAME_OVER). Sense
// pre-load, la primera reproducció faria disc amb el countdown ja
// en marxa.
const auto& key = game_over_config_.countdown_sound_key;
if (key.empty()) { return; }
Resource::Cache::get().loadSound(
key,
std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + key);
}
void GameScene::handleJoinInput() {
// El "join in progress" es processa durant PLAYING (regla 1) i,
// com a cas especial, durant CONTINUE_PROMPT — ahí un START reviu
// el jugador i cancel·la el countdown. A partir de GAME_OVER el
// START queda bloquejat (regla 5: ja no es pot pulsar start).
const bool IN_PLAYING = (state_ == RoomFsm::State::PLAYING);
const bool IN_CONTINUE = (game_over_state_ == GameOverFsm::State::CONTINUE_PROMPT);
const bool IN_GAME_OVER = (game_over_state_ != GameOverFsm::State::OFF && game_over_state_ != GameOverFsm::State::CONTINUE_PROMPT);
if (IN_GAME_OVER) { return; }
if (!IN_PLAYING && !IN_CONTINUE) { return; }
for (std::size_t i = 0; i < players_.size(); ++i) {
if (players_active_[i]) { continue; }
if (!players_[i]->isDisabled()) { continue; }
if (!Input::Detect::pressedStart(static_cast<int>(i))) { continue; }
joinPlayer(i);
// Si veníem del countdown de CONTINUE?, disparem la sortida
// visual del rètol. El jugador ja apareix en escena per la
// crida prèvia a `joinPlayer`; el text marxa amb la mateixa
// exit anim que el room text.
if (IN_CONTINUE) {
GameOverFsm::cancelContinuePrompt(game_over_state_, game_over_transition_);
}
}
}
void GameScene::joinPlayer(std::size_t slot_index) {
if (slot_index >= players_.size()) { return; }
// Spawn lateral SEMPRE: cada jugador entra pel seu costat de la
// porta de sortida. Mai el centre (en single-player la cinemàtica
// inicial sí que centra el slot actiu, però en un join mid-partida
// volem el costat per coherència visual amb la regla 2).
const auto SPAWN = computeSpawn();
const float X = (slot_index == 0) ? SPAWN.p1_x : SPAWN.p2_x;
players_[slot_index]->setSpawnPoint(X, SPAWN.y);
players_[slot_index]->resetForJoin();
players_[slot_index]->spawnFromAir(X, SPAWN.y);
players_active_[slot_index] = true;
if (hud_) { hud_->activatePlayer(static_cast<int>(slot_index) + 1); }
Log::info("ROOM", "join mid-game P{} -> spawn aerial at ({}, {})", slot_index + 1, X, SPAWN.y);
}
auto GameScene::activePlayerCount() const -> int {
int n = 0;
for (const auto& p : players_) {
if (!p) { continue; }
if (!p->isOutOfPlay() || p->isDying()) { ++n; }
}
return n;
}
void GameScene::refreshJoinPrompt() {
if (!hud_) { return; }
// El game over substitueix la decisió purament basada en RoomFsm:
// durant CONTINUE_PROMPT els jugadors encara poden entrar (revive),
// a partir de GAME_OVER ja no, així que mostrem WAIT (o NONE) per
// indicar que el START ja no farà res fins a la transició a LOGO.
Hud::JoinPromptMode mode_inactive = Hud::JoinPromptMode::NONE;
if (game_over_state_ == GameOverFsm::State::CONTINUE_PROMPT) {
mode_inactive = Hud::JoinPromptMode::CAN_JOIN;
} else if (game_over_state_ != GameOverFsm::State::OFF) {
mode_inactive = Hud::JoinPromptMode::WAIT;
} else {
switch (state_) {
case RoomFsm::State::PLAYING:
mode_inactive = Hud::JoinPromptMode::CAN_JOIN;
break;
case RoomFsm::State::ROOM_INTRO:
case RoomFsm::State::ROOM_OUTRO:
mode_inactive = Hud::JoinPromptMode::WAIT;
break;
}
}
for (std::size_t i = 0; i < players_.size(); ++i) {
const auto MODE = players_active_[i] ? Hud::JoinPromptMode::NONE : mode_inactive;
hud_->setJoinPrompt(static_cast<int>(i) + 1, MODE);
}
}
void GameScene::completeIntro() {
if (intro_completed_) { return; }
intro_completed_ = true;
@@ -697,9 +830,9 @@ void GameScene::completeIntro() {
void GameScene::spawnDuePlayers() {
if (!intro_completed_) { return; }
const bool BOTH_ACTIVE = players_starts_active_[0] && players_starts_active_[1];
for (std::size_t i = 0; i < players_starts_active_.size(); ++i) {
if (!players_starts_active_[i]) { continue; }
const bool BOTH_ACTIVE = players_active_[0] && players_active_[1];
for (std::size_t i = 0; i < players_active_.size(); ++i) {
if (!players_active_[i]) { continue; }
if (players_spawned_in_intro_[i]) { continue; }
// Single-player: el slot actiu (P1 o P2) sempre fa servir
// `player_spawn_delay_ms`. Mode BOTH: P1 fa player_spawn_delay_ms,
@@ -713,7 +846,7 @@ void GameScene::spawnDuePlayers() {
void GameScene::setState(RoomFsm::State new_state) {
if (new_state == state_) { return; }
std::cout << "[ROOM] " << RoomFsm::stateName(state_) << " -> " << RoomFsm::stateName(new_state) << '\n';
Log::info("ROOM", "{} -> {}", RoomFsm::stateName(state_), RoomFsm::stateName(new_state));
state_ = new_state;
transition_ = {};
// Reset defensiu dels flags de cicle quan tornem a INTRO via aquest
@@ -742,7 +875,7 @@ void GameScene::setState(RoomFsm::State new_state) {
const int TEXT_TOTAL_MS = transition_config_.outro_text_enter_ms + transition_config_.outro_text_hold_ms + transition_config_.outro_text_exit_ms;
transition_config_.outro_music_tail_ms = 0;
if (audio == nullptr) {
std::cout << "[ROOM] OUTRO: Audio absent — sense música, timeline només contra text\n";
Log::warn("ROOM", "OUTRO: Audio absent — sense música, timeline només contra text");
} else {
audio->stopMusic();
audio->setOnMusicEnded({});
@@ -750,10 +883,9 @@ void GameScene::setState(RoomFsm::State new_state) {
if (Audio::getMusicState() == Audio::MusicState::PLAYING) {
const int DUR_MS = Audio::getMusicDurationMs("room_outro.ogg");
transition_config_.outro_music_tail_ms = std::max(0, DUR_MS - TEXT_TOTAL_MS);
std::cout << "[ROOM] OUTRO: música " << DUR_MS << " ms, music_tail "
<< transition_config_.outro_music_tail_ms << " ms\n";
Log::info("ROOM", "OUTRO: música {} ms, music_tail {} ms", DUR_MS, transition_config_.outro_music_tail_ms);
} else {
std::cout << "[ROOM] OUTRO: música no arrencada — timeline només contra text\n";
Log::warn("ROOM", "OUTRO: música no arrencada — timeline només contra text");
}
}
}
@@ -856,18 +988,20 @@ void GameScene::renderRoomText() {
// l'antic GameCanvas::CENTER_Y per descomptar la franja del marcador
// i tenir el rètol equilibrat sobre el playfield.
const int PLAY_CENTER_Y = (GameCanvas::HEIGHT + Map::hud_h) / 2;
// Colors del rètol via UiConfig (`data/ui.yaml > colors.room_text` i
// Colors del rètol via UiConfig (`data/config/ui.yaml > colors.room_text` i
// `room_text_outline`). Independents dels colors de notificació.
const Uint8 FILL = UiConfig::get().roomText();
const Uint8 OUTLINE = UiConfig::get().roomTextOutline();
const int LINE_HEIGHT = room_text_font_->getLineHeight();
if (state_ == RoomFsm::State::ROOM_INTRO && transition_.phase >= 2 && transition_.phase <= 4) {
const int PHASE = transition_.phase;
const int ELAPSED = transition_.elapsed_ms;
if (state_ == RoomFsm::State::ROOM_INTRO && PHASE >= 2 && PHASE <= 4) {
const int DUR_ENTER = transition_config_.intro_text_enter_ms;
const int DUR_EXIT = transition_config_.intro_text_exit_ms;
// Panel primer (queda sota el text gràcies a l'ordre de blits).
drawRoomTextPanel(PLAY_CENTER_Y,
computeRoomTextPanelHeight(2, 3, 4, DUR_ENTER, DUR_EXIT));
computeRoomTextPanelHeight(PHASE, ELAPSED, 2, 3, 4, DUR_ENTER, DUR_EXIT));
const auto& mgr = Levels::LevelManager::get();
const std::string LINE_PYRAMID = RoomFsm::formatIntroPyramidLine(mgr.currentPyramid());
@@ -875,12 +1009,16 @@ void GameScene::renderRoomText() {
// Direccions oposades: piràmide entra per l'esquerra i surt per
// la dreta; sala fa el contrari.
const int X_PYRAMID = computeRoomTextX(room_text_font_->length(LINE_PYRAMID, KERNING),
PHASE,
ELAPSED,
2,
4,
DUR_ENTER,
DUR_EXIT,
/*from_right=*/false);
const int X_ROOM = computeRoomTextX(room_text_font_->length(LINE_ROOM, KERNING),
PHASE,
ELAPSED,
2,
4,
DUR_ENTER,
@@ -892,21 +1030,25 @@ void GameScene::renderRoomText() {
const int GAP = transition_config_.intro_text_line_gap_px;
drawRoomTextLine(LINE_PYRAMID, X_PYRAMID, PLAY_CENTER_Y - LINE_HEIGHT - (GAP / 2), FILL, OUTLINE);
drawRoomTextLine(LINE_ROOM, X_ROOM, PLAY_CENTER_Y + (GAP / 2), FILL, OUTLINE);
} else if (state_ == RoomFsm::State::ROOM_OUTRO && transition_.phase >= 0 && transition_.phase <= 2) {
} else if (state_ == RoomFsm::State::ROOM_OUTRO && PHASE >= 0 && PHASE <= 2) {
const int DUR_ENTER = transition_config_.outro_text_enter_ms;
const int DUR_EXIT = transition_config_.outro_text_exit_ms;
drawRoomTextPanel(PLAY_CENTER_Y,
computeRoomTextPanelHeight(0, 1, 2, DUR_ENTER, DUR_EXIT));
computeRoomTextPanelHeight(PHASE, ELAPSED, 0, 1, 2, DUR_ENTER, DUR_EXIT));
const std::string LINE_1 = Locale::get().text("game.room_complete_line1");
const std::string LINE_2 = Locale::get().text("game.room_complete_line2");
const int X_1 = computeRoomTextX(room_text_font_->length(LINE_1, KERNING),
PHASE,
ELAPSED,
0,
2,
DUR_ENTER,
DUR_EXIT,
/*from_right=*/false);
const int X_2 = computeRoomTextX(room_text_font_->length(LINE_2, KERNING),
PHASE,
ELAPSED,
0,
2,
DUR_ENTER,
@@ -918,7 +1060,7 @@ void GameScene::renderRoomText() {
}
}
auto GameScene::computeRoomTextX(int width, int phase_enter, int phase_exit, int dur_enter, int dur_exit, bool from_right) const -> int {
auto GameScene::computeRoomTextX(int width, int phase, int elapsed_ms, int phase_enter, int phase_exit, int dur_enter, int dur_exit, bool from_right) const -> int {
// X animada d'una línia: entra des de fora de pantalla, s'atura al
// CENTER_X durant el hold (fora del rang d'aquesta funció — el caller
// garanteix que cau a la branca return final) i surt pel costat
@@ -928,16 +1070,16 @@ auto GameScene::computeRoomTextX(int width, int phase_enter, int phase_exit, int
const int OFF_RIGHT = GameCanvas::WIDTH;
const int OFF_ENTER = from_right ? OFF_RIGHT : OFF_LEFT;
const int OFF_EXIT = from_right ? OFF_LEFT : OFF_RIGHT;
if (transition_.phase == phase_enter) {
if (phase == phase_enter) {
const float T = (dur_enter > 0)
? std::clamp(static_cast<float>(transition_.elapsed_ms) / static_cast<float>(dur_enter), 0.0F, 1.0F)
? std::clamp(static_cast<float>(elapsed_ms) / static_cast<float>(dur_enter), 0.0F, 1.0F)
: 1.0F;
const auto CURVE = (transition_config_.text_enter_curve != nullptr) ? transition_config_.text_enter_curve : &Easing::linear;
return Easing::lerpInt(OFF_ENTER, GameCanvas::CENTER_X, CURVE(T));
}
if (transition_.phase == phase_exit) {
if (phase == phase_exit) {
const float T = (dur_exit > 0)
? std::clamp(static_cast<float>(transition_.elapsed_ms) / static_cast<float>(dur_exit), 0.0F, 1.0F)
? std::clamp(static_cast<float>(elapsed_ms) / static_cast<float>(dur_exit), 0.0F, 1.0F)
: 1.0F;
const auto CURVE = (transition_config_.text_exit_curve != nullptr) ? transition_config_.text_exit_curve : &Easing::linear;
return Easing::lerpInt(GameCanvas::CENTER_X, OFF_EXIT, CURVE(T));
@@ -945,23 +1087,23 @@ auto GameScene::computeRoomTextX(int width, int phase_enter, int phase_exit, int
return GameCanvas::CENTER_X;
}
auto GameScene::computeRoomTextPanelHeight(int phase_enter, int phase_hold, int phase_exit, int dur_enter, int dur_exit) const -> int {
auto GameScene::computeRoomTextPanelHeight(int phase, int elapsed_ms, int phase_enter, int phase_hold, int phase_exit, int dur_enter, int dur_exit) const -> int {
// Altura animada del panel translúcid sota el rètol. Creix de 0 a
// `text_panel_height_px` amb `text_enter_curve` durant l'enter, es
// manté plena durant el hold i decreix amb `text_exit_curve`
// durant l'exit. Fora d'aquestes tres fases retorna 0.
const int MAX = transition_config_.text_panel_height_px;
if (transition_.phase == phase_enter) {
if (phase == phase_enter) {
const float T = (dur_enter > 0)
? std::clamp(static_cast<float>(transition_.elapsed_ms) / static_cast<float>(dur_enter), 0.0F, 1.0F)
? std::clamp(static_cast<float>(elapsed_ms) / static_cast<float>(dur_enter), 0.0F, 1.0F)
: 1.0F;
const auto CURVE = (transition_config_.text_enter_curve != nullptr) ? transition_config_.text_enter_curve : &Easing::linear;
return Easing::lerpInt(0, MAX, CURVE(T));
}
if (transition_.phase == phase_hold) { return MAX; }
if (transition_.phase == phase_exit) {
if (phase == phase_hold) { return MAX; }
if (phase == phase_exit) {
const float T = (dur_exit > 0)
? std::clamp(static_cast<float>(transition_.elapsed_ms) / static_cast<float>(dur_exit), 0.0F, 1.0F)
? std::clamp(static_cast<float>(elapsed_ms) / static_cast<float>(dur_exit), 0.0F, 1.0F)
: 1.0F;
const auto CURVE = (transition_config_.text_exit_curve != nullptr) ? transition_config_.text_exit_curve : &Easing::linear;
return Easing::lerpInt(MAX, 0, CURVE(T));
@@ -994,6 +1136,250 @@ void GameScene::drawRoomTextLine(const std::string& text, int x, int y, std::uin
outline_color);
}
void GameScene::setGameOverState(GameOverFsm::State new_state) {
if (new_state == game_over_state_) { return; }
const auto PREV = game_over_state_;
Log::info("GAMEOVER", "{} -> {}", GameOverFsm::stateName(PREV), GameOverFsm::stateName(new_state));
game_over_state_ = new_state;
game_over_transition_ = {};
auto* audio = Audio::get();
// Sortida de CONTINUE_PROMPT cap a OFF (revive): reinici de la
// música de joc des del principi a velocitat normal. Reaprofitem
// el track gravat a `config_.music_track` (sempre `playing.ogg`
// amb loop infinit). Si la sortida és cap a GAME_OVER, la branca
// de més avall ja talla la música i posa `game_over.ogg` — aquí
// saltem perquè el stop+play seria redundant.
if (PREV == GameOverFsm::State::CONTINUE_PROMPT && new_state == GameOverFsm::State::OFF && audio != nullptr) {
audio->stopMusic();
audio->playMusic(config_.music_track, -1);
audio->setMusicSpeed(1.0F);
}
if (new_state == GameOverFsm::State::CONTINUE_PROMPT && audio != nullptr) {
// Acceleració arcade: stop+play perquè la pista arrenqui des
// del principi al ritme accelerat (sense aquest reinici, el
// canvi de velocitat sobre la posició actual queda poc
// perceptible amb ratios subtils com 1.05). El to també puja:
// és l'efecte clàssic dels comptes enrere.
audio->stopMusic();
audio->playMusic(config_.music_track, -1);
audio->setMusicSpeed(game_over_config_.continue_music_speed);
}
// Reset del tracker del so del countdown cada vegada que entrem
// a CONTINUE_PROMPT, perquè la primera aparició del valor inicial
// (`continue_count_start`) dispari el "tick". Fora de
// CONTINUE_PROMPT no s'usa.
if (new_state == GameOverFsm::State::CONTINUE_PROMPT) {
last_countdown_value_ = -1;
}
if (new_state == GameOverFsm::State::GAME_OVER && audio != nullptr) {
// Talla la música actual i arrenca la pista de GAME OVER sense
// loop. La fase de hold es redimensiona a la durada real de la
// pista perquè el text es mantingui visible mentre dura la
// música; l'exit anim, el wait i el fade arrenquen quan la
// música ja s'ha drenat.
audio->stopMusic();
const auto& track = game_over_config_.game_over_music_track;
if (!track.empty()) {
audio->playMusic(track, 0);
const int DUR_MS = Audio::getMusicDurationMs(track);
if (DUR_MS > 0) {
game_over_config_.game_over_text_hold_ms = DUR_MS;
Log::info("GAMEOVER", "{}: durada {} ms (text_hold sincronitzat)", track, DUR_MS);
}
}
}
if (new_state == GameOverFsm::State::FADE_OUT) {
// Arma el fade en el moment que entrem a la fase de fade-out.
// Mateix patró que `outro_fade_started_` per a OUTRO de sala.
game_over_fade_.begin(game_over_config_.fade_out_ms,
Transitions::FadeToBlack::Direction::FADE_OUT);
game_over_fade_started_ = true;
}
}
void GameScene::updateGameOverFlow(int delta_ms) {
// Tick del fade visual: és no-op si encara no s'ha armat (begin
// sense duration → tick=no-op gràcies al clamp intern).
game_over_fade_.tick(delta_ms);
// Detecció d'entrada a CONTINUE_PROMPT: tots els jugadors fora de
// partida durant PLAYING. Només es dispara una vegada — `setGameOverState`
// és idempotent davant del mateix estat. Es vol que la transició
// sigui exclusiva de PLAYING perquè un join mid-INTRO/OUTRO ja no
// és possible (vegeu `handleJoinInput`); permetent CONTINUE? fora
// de PLAYING obriríem races amb la cinemàtica de sala.
if (game_over_state_ == GameOverFsm::State::OFF && state_ == RoomFsm::State::PLAYING && activePlayerCount() == 0) {
setGameOverState(GameOverFsm::State::CONTINUE_PROMPT);
return;
}
if (game_over_state_ == GameOverFsm::State::OFF) { return; }
const auto NEXT = GameOverFsm::advance(game_over_state_, game_over_transition_, game_over_config_, delta_ms);
if (NEXT != game_over_state_) {
setGameOverState(NEXT);
// En arribar a DONE, demanem la transició a LOGO. La construcció
// de la nova escena la fa el Director llegint `exit_request_`.
if (NEXT == GameOverFsm::State::DONE) {
exit_request_ = ExitRequest{.next = SceneId::LOGO, .params = LogoParams{}};
Log::info("GAMEOVER", "DONE -> requesting LOGO scene");
}
}
// Skip i tick del so del countdown viuen en helpers per acotar
// la complexitat cognitiva d'aquesta funció. Skip primer perquè
// el tick del so soni amb el valor nou si la pulsació ha avançat.
handleCountdownSkip();
emitCountdownTickIfChanged();
}
void GameScene::handleCountdownSkip() {
if (game_over_state_ != GameOverFsm::State::CONTINUE_PROMPT) { return; }
if (game_over_transition_.phase != 1) { return; }
const int CURRENT = GameOverFsm::countdownSecondsRemaining(
game_over_state_,
game_over_transition_,
game_over_config_);
if (CURRENT <= 0) { return; }
if (CURRENT > game_over_config_.skip_below_or_equal) { return; }
const bool ANY_ACTION = Input::Detect::pressedActionButton(0) || Input::Detect::pressedActionButton(1);
if (!ANY_ACTION) { return; }
GameOverFsm::skipToNextCountdown(
game_over_state_,
game_over_transition_,
game_over_config_);
}
void GameScene::emitCountdownTickIfChanged() {
if (game_over_state_ != GameOverFsm::State::CONTINUE_PROMPT) { return; }
if (game_over_transition_.phase != 1) { return; }
const int CURRENT = GameOverFsm::countdownSecondsRemaining(
game_over_state_,
game_over_transition_,
game_over_config_);
if (CURRENT == last_countdown_value_) { return; }
last_countdown_value_ = CURRENT;
const auto& key = game_over_config_.countdown_sound_key;
if (key.empty()) { return; }
if (auto* audio = Audio::get(); audio != nullptr) {
audio->playSound(key, Audio::Group::INTERFACE);
}
}
void GameScene::renderGameOverText() {
if (!room_text_font_) { return; }
if (game_over_state_ != GameOverFsm::State::CONTINUE_PROMPT && game_over_state_ != GameOverFsm::State::GAME_OVER) {
return;
}
constexpr int KERNING = 0;
const int PLAY_CENTER_Y = (GameCanvas::HEIGHT + Map::hud_h) / 2;
const Uint8 FILL = UiConfig::get().roomText();
const Uint8 OUTLINE = UiConfig::get().roomTextOutline();
const int LINE_HEIGHT = room_text_font_->getLineHeight();
const int PHASE = game_over_transition_.phase;
const int ELAPSED = game_over_transition_.elapsed_ms;
if (game_over_state_ == GameOverFsm::State::CONTINUE_PROMPT) {
const int DUR_ENTER = game_over_config_.continue_text_enter_ms;
const int DUR_EXIT = game_over_config_.continue_text_exit_ms;
// Phases del CONTINUE_PROMPT: 0=enter, 1=hold, 2=exit. El panel
// segueix la mateixa corba enter/hold/exit que el room text.
drawRoomTextPanel(PLAY_CENTER_Y,
computeRoomTextPanelHeight(PHASE, ELAPSED, 0, 1, 2, DUR_ENTER, DUR_EXIT));
const std::string LINE_1 = Locale::get().text("game.continue_prompt");
const int SECONDS = GameOverFsm::countdownSecondsRemaining(
game_over_state_,
game_over_transition_,
game_over_config_);
// Línia 2 només té text durant la fase de hold (countdown). En
// les fases d'enter i exit deixem només el "CONTINUE?" perquè
// l'animació dels números no quedi rara.
const std::string LINE_2 = (PHASE == 1) ? std::to_string(SECONDS) : std::string{};
const int X_1 = computeRoomTextX(room_text_font_->length(LINE_1, KERNING),
PHASE,
ELAPSED,
0,
2,
DUR_ENTER,
DUR_EXIT,
/*from_right=*/false);
const int X_2 = computeRoomTextX(LINE_2.empty() ? 0 : room_text_font_->length(LINE_2, KERNING),
PHASE,
ELAPSED,
0,
2,
DUR_ENTER,
DUR_EXIT,
/*from_right=*/true);
const int GAP = transition_config_.intro_text_line_gap_px;
drawRoomTextLine(LINE_1, X_1, PLAY_CENTER_Y - LINE_HEIGHT - (GAP / 2), FILL, OUTLINE);
if (!LINE_2.empty()) {
drawRoomTextLine(LINE_2, X_2, PLAY_CENTER_Y + (GAP / 2), FILL, OUTLINE);
}
return;
}
// GAME_OVER: panel sempre amunt durant phase 0/1, decreix en phase 1
// (exit). Phase 2 (wait) sense panel ni text — només temps mort
// abans del fade.
const int DUR_HOLD = game_over_config_.game_over_text_hold_ms;
const int DUR_EXIT = game_over_config_.game_over_text_exit_ms;
if (PHASE > 1) { return; }
// Panel: phase 0 = ple, phase 1 = decreix. Reaprofitem
// computeRoomTextPanelHeight passant un phase_enter "fora de rang"
// (-1) perquè la branca d'enter no s'activi mai.
drawRoomTextPanel(PLAY_CENTER_Y,
computeRoomTextPanelHeight(PHASE, ELAPSED, /*phase_enter=*/-1,
/*phase_hold=*/0,
/*phase_exit=*/1,
/*dur_enter=*/0,
DUR_EXIT));
const std::string LINE_1 = Locale::get().text("game.game_over_line1");
const std::string LINE_2 = Locale::get().text("game.game_over_line2");
// Phase 0 (hold): text quiet al CENTER_X. Phase 1 (exit): el text
// marxa pels costats com el room outro. Reaprofitem computeRoomTextX
// amb phase_enter=-1 (no enter anim) i phase_exit=1.
const int X_1 = computeRoomTextX(room_text_font_->length(LINE_1, KERNING),
PHASE,
ELAPSED,
/*phase_enter=*/-1,
/*phase_exit=*/1,
/*dur_enter=*/0,
DUR_EXIT,
/*from_right=*/false);
const int X_2 = computeRoomTextX(room_text_font_->length(LINE_2, KERNING),
PHASE,
ELAPSED,
/*phase_enter=*/-1,
/*phase_exit=*/1,
/*dur_enter=*/0,
DUR_EXIT,
/*from_right=*/true);
const int GAP = transition_config_.outro_text_line_gap_px;
drawRoomTextLine(LINE_1, X_1, PLAY_CENTER_Y - LINE_HEIGHT - (GAP / 2), FILL, OUTLINE);
drawRoomTextLine(LINE_2, X_2, PLAY_CENTER_Y + (GAP / 2), FILL, OUTLINE);
(void)DUR_HOLD; // Reservat per a futurs efectes de hold (pulse, etc.).
}
void GameScene::renderGameOverFade() {
// Sense `begin` previ, `tick` és no-op i `render` pinta alpha=0
// (vegeu `Transitions::FadeToBlack`). Així el callsite és
// incondicional sense malbaratar pixel ops fora del flux.
if (!game_over_fade_started_) { return; }
game_over_fade_.render();
}
void GameScene::spawnDust(float feet_x_center, float feet_y, bool is_landing) {
// Mida del sprite de Dust llegida del seu YAML. Hardcoded per
// simplificar — si canvia, només cal tocar dust.yaml + aquesta
@@ -1075,7 +1461,7 @@ void GameScene::handlePauseInput() {
pause_.curve = GOING_TO_PAUSE ? UiConfig::get().pauseEnterCurve() : UiConfig::get().pauseExitCurve();
pause_.state = GOING_TO_PAUSE ? PauseState::ENTERING : PauseState::EXITING;
std::cout << "[PAUSE] " << (GOING_TO_PAUSE ? "entering" : "exiting") << " from_scale=" << pause_.from_scale << " duration_ms=" << pause_.duration_ms << '\n';
Log::info("PAUSE", "{} from_scale={} duration_ms={}", GOING_TO_PAUSE ? "entering" : "exiting", pause_.from_scale, pause_.duration_ms);
}
}
@@ -1166,6 +1552,12 @@ void GameScene::update(int delta_ms) {
// arrenque ja sigui efectiu en el mateix frame.
updateRoomTransition(delta_ms);
// Sub-FSM de game over (CONTINUE? + GAME OVER + fade-to-LOGO).
// També en temps real: el countdown de CONTINUE? no es congela
// amb la pausa de gameplay. La detecció de "tots fora de partida"
// s'arma dins quan estem a PLAYING.
updateGameOverFlow(delta_ms);
#ifdef _DEBUG
// Toggle de l'editor: només si no hi ha cap transició de pausa ni
// pausa consolidada. L'editor mateix no consumeix la tecla — la
@@ -1212,11 +1604,25 @@ void GameScene::update(int delta_ms) {
// (creuar → player_exited → level_complete → OUTRO) es manté.
// Mateixos gates que el salt entre sales.
if (pause_.state == PauseState::IDLE && !editor_.active() && detectOpenDoor()) {
std::cout << "[DEBUG] força obertura de la porta\n";
Log::info("DEBUG", "força obertura de la porta");
entity_manager_.forceOpenDoor();
}
#endif
// Join in progress (regla 1): un slot DISABLED pot entrar a partida
// pulsant START durant PLAYING. Es processa abans del bloc de món
// per que el spawn aeri ja sigui efectiu en aquest mateix frame.
// Gateat per pausa/menú/editor: no es vol que un START accidental
// mentre l'usuari navega per un menú trigueri un join.
#ifdef _DEBUG
const bool EDITOR_OPEN = editor_.active();
#else
constexpr bool EDITOR_OPEN = false;
#endif
if (pause_.state == PauseState::IDLE && !MENU_FROZEN && !EDITOR_OPEN) {
handleJoinInput();
}
GlobalInputs::update(delta_ms);
if (GlobalInputs::wantsQuit()) { wants_quit_ = true; }
@@ -1277,6 +1683,11 @@ void GameScene::update(int delta_ms) {
RenderInfo::get().update(delta_ms, Options::buildRenderInfoFrame(UiConfig::get().renderInfoText(), UiConfig::get().renderInfoShadow()));
Notifier::get().update(delta_ms);
// Refresc del prompt al HUD per a slots inactius. Es fa al final per
// recollir els canvis d'aquest frame (joins, deaths, transició de
// RoomFsm) abans del proper render.
refreshJoinPrompt();
}
void GameScene::render() {
@@ -1375,6 +1786,14 @@ void GameScene::render() {
// Text de la transició de sala (entrada o sortida): per damunt del
// HUD perquè és UI de transició i ha de ser sempre llegible.
renderRoomText();
// Rètol de game over (CONTINUE? + countdown / GAME OVER) — comparteix
// panel i font amb el room text però viu en una sub-FSM independent.
renderGameOverText();
// Fade-to-black final del game over: tapeix tot el canvas (HUD
// inclòs) durant la fase FADE_OUT i el black hold posterior.
renderGameOverFade();
}
#ifdef _DEBUG
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff. Show more