Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cbf1bb6e0 | ||
|
|
8890fa9e1f | ||
|
|
23f7cb03a6 | ||
|
|
486a440668 | ||
|
|
a717411edd | ||
|
|
5e862ceadc | ||
|
|
78e164fce4 | ||
|
|
c13b11c501 | ||
|
|
8921b9fd0c | ||
|
|
1d9fea28fe | ||
|
|
d0d5cb0e66 | ||
|
|
e25ccd02fd | ||
|
|
3635cebf25 | ||
|
|
cfbcdc236b | ||
|
|
7871d3930e | ||
|
|
932b457725 | ||
|
|
e9f99a0542 | ||
|
|
0d58da536c | ||
|
|
583acb9053 |
No files matched your search
@@ -107,16 +107,19 @@ set(APP_SOURCES
|
|||||||
source/utils/log.cpp
|
source/utils/log.cpp
|
||||||
source/utils/utils.cpp
|
source/utils/utils.cpp
|
||||||
source/game/debug_config.cpp
|
source/game/debug_config.cpp
|
||||||
|
source/game/gameplay_config.cpp
|
||||||
source/game/options.cpp
|
source/game/options.cpp
|
||||||
source/game/options_apply.cpp
|
source/game/options_apply.cpp
|
||||||
source/game/ui/service_menu.cpp
|
source/game/ui/service_menu.cpp
|
||||||
source/game/ui/hud.cpp
|
source/game/ui/hud.cpp
|
||||||
source/game/scenes/placeholder_input.cpp
|
source/game/scenes/placeholder_input.cpp
|
||||||
|
source/game/scenes/start_input.cpp
|
||||||
source/game/scenes/logo_scene.cpp
|
source/game/scenes/logo_scene.cpp
|
||||||
source/game/scenes/title_scene.cpp
|
source/game/scenes/title_scene.cpp
|
||||||
source/game/scenes/intro_scene.cpp
|
source/game/scenes/intro_scene.cpp
|
||||||
source/game/scenes/game_scene.cpp
|
source/game/scenes/game_scene.cpp
|
||||||
source/game/scenes/room_fsm.cpp
|
source/game/scenes/room_fsm.cpp
|
||||||
|
source/game/scenes/game_over_fsm.cpp
|
||||||
source/game/entities/player.cpp
|
source/game/entities/player.cpp
|
||||||
source/game/map/map.cpp
|
source/game/map/map.cpp
|
||||||
source/game/map/tilemap.cpp
|
source/game/map/tilemap.cpp
|
||||||
@@ -397,6 +400,7 @@ if(BUILD_TESTS)
|
|||||||
tests/audio_cache_stubs.cpp
|
tests/audio_cache_stubs.cpp
|
||||||
source/core/resources/resource_helper.cpp
|
source/core/resources/resource_helper.cpp
|
||||||
source/core/resources/resource_pack.cpp
|
source/core/resources/resource_pack.cpp
|
||||||
|
source/game/gameplay_config.cpp
|
||||||
source/game/map/map.cpp
|
source/game/map/map.cpp
|
||||||
source/utils/log.cpp
|
source/utils/log.cpp
|
||||||
${ENTITY_SOURCES_BASE}
|
${ENTITY_SOURCES_BASE}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -38,6 +38,13 @@ hud:
|
|||||||
treasures: "Tres"
|
treasures: "Tres"
|
||||||
pyramid: "Piramide"
|
pyramid: "Piramide"
|
||||||
room: "Sala"
|
room: "Sala"
|
||||||
|
# Prompts mostrats al bloc d'un slot inactiu (en lloc dels zeros)
|
||||||
|
# quan un jugador encara no ha pulsat START o ha esgotat vides.
|
||||||
|
# Dues línies per fer-los caber al bloc lateral (~106 px).
|
||||||
|
join_prompt_can_join_l1: "POLSA START"
|
||||||
|
join_prompt_can_join_l2: "PER JUGAR"
|
||||||
|
join_prompt_wait_l1: "ESPERA"
|
||||||
|
join_prompt_wait_l2: "PER JUGAR"
|
||||||
|
|
||||||
# Textos de l'escena TITLE.
|
# Textos de l'escena TITLE.
|
||||||
title:
|
title:
|
||||||
@@ -53,6 +60,12 @@ game:
|
|||||||
room_intro_room: "Sala {r}"
|
room_intro_room: "Sala {r}"
|
||||||
room_complete_line1: "Fase"
|
room_complete_line1: "Fase"
|
||||||
room_complete_line2: "Completada"
|
room_complete_line2: "Completada"
|
||||||
|
# Rètol del game over (sub-FSM de la GameScene). El countdown es
|
||||||
|
# pinta com a segona línia (un enter) durant la fase 1 de
|
||||||
|
# CONTINUE_PROMPT — només cal traduir el rètol superior.
|
||||||
|
continue_prompt: "Continuar?"
|
||||||
|
game_over_line1: "Fi del"
|
||||||
|
game_over_line2: "Joc"
|
||||||
|
|
||||||
menu:
|
menu:
|
||||||
titles:
|
titles:
|
||||||
@@ -62,6 +75,7 @@ menu:
|
|||||||
controls: "Controls"
|
controls: "Controls"
|
||||||
keyboard: "Teclat"
|
keyboard: "Teclat"
|
||||||
gamepad: "Comandament"
|
gamepad: "Comandament"
|
||||||
|
tweaks: "Jugabilitat"
|
||||||
system: "Sistema"
|
system: "Sistema"
|
||||||
labels:
|
labels:
|
||||||
player_1: "Jugador 1"
|
player_1: "Jugador 1"
|
||||||
@@ -73,6 +87,7 @@ menu:
|
|||||||
controls_submenu: "Controls"
|
controls_submenu: "Controls"
|
||||||
keyboard_submenu: "Teclat"
|
keyboard_submenu: "Teclat"
|
||||||
gamepad_submenu: "Comandament"
|
gamepad_submenu: "Comandament"
|
||||||
|
tweaks_submenu: "Jugabilitat"
|
||||||
system_submenu: "Sistema"
|
system_submenu: "Sistema"
|
||||||
move_up: "Amunt"
|
move_up: "Amunt"
|
||||||
move_down: "Avall"
|
move_down: "Avall"
|
||||||
@@ -101,6 +116,9 @@ menu:
|
|||||||
music_volume: "Volum música"
|
music_volume: "Volum música"
|
||||||
sounds: "Sons"
|
sounds: "Sons"
|
||||||
sounds_volume: "Volum sons"
|
sounds_volume: "Volum sons"
|
||||||
|
whip_pierces_walls: "Látec travessa sarcs"
|
||||||
|
friendly_fire: "Foc amic"
|
||||||
|
players_collide: "Col·lisió entre jugadors"
|
||||||
restart: "Reiniciar"
|
restart: "Reiniciar"
|
||||||
exit_game: "Eixir del joc"
|
exit_game: "Eixir del joc"
|
||||||
values:
|
values:
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ hud:
|
|||||||
treasures: "Tre"
|
treasures: "Tre"
|
||||||
pyramid: "Pyramid"
|
pyramid: "Pyramid"
|
||||||
room: "Room"
|
room: "Room"
|
||||||
|
# Prompts shown in an inactive slot's block (instead of zeros) while
|
||||||
|
# a player hasn't pressed START yet or has run out of lives. Two
|
||||||
|
# short lines so they fit in the side block (~106 px wide).
|
||||||
|
join_prompt_can_join_l1: "PRESS START"
|
||||||
|
join_prompt_can_join_l2: "TO PLAY"
|
||||||
|
join_prompt_wait_l1: "PLEASE"
|
||||||
|
join_prompt_wait_l2: "WAIT"
|
||||||
|
|
||||||
# Texts of the TITLE scene.
|
# Texts of the TITLE scene.
|
||||||
title:
|
title:
|
||||||
@@ -52,6 +59,12 @@ game:
|
|||||||
room_intro_room: "Room {r}"
|
room_intro_room: "Room {r}"
|
||||||
room_complete_line1: "Stage"
|
room_complete_line1: "Stage"
|
||||||
room_complete_line2: "Complete"
|
room_complete_line2: "Complete"
|
||||||
|
# Game over labels (sub-FSM in GameScene). The countdown is drawn as
|
||||||
|
# a numeric second line during CONTINUE_PROMPT phase 1, so only the
|
||||||
|
# top label needs translation.
|
||||||
|
continue_prompt: "Continue?"
|
||||||
|
game_over_line1: "Game"
|
||||||
|
game_over_line2: "Over"
|
||||||
|
|
||||||
menu:
|
menu:
|
||||||
titles:
|
titles:
|
||||||
@@ -61,6 +74,7 @@ menu:
|
|||||||
controls: "Controls"
|
controls: "Controls"
|
||||||
keyboard: "Keyboard"
|
keyboard: "Keyboard"
|
||||||
gamepad: "Gamepad"
|
gamepad: "Gamepad"
|
||||||
|
tweaks: "Gameplay"
|
||||||
system: "System"
|
system: "System"
|
||||||
labels:
|
labels:
|
||||||
player_1: "Player 1"
|
player_1: "Player 1"
|
||||||
@@ -72,6 +86,7 @@ menu:
|
|||||||
controls_submenu: "Controls"
|
controls_submenu: "Controls"
|
||||||
keyboard_submenu: "Keyboard"
|
keyboard_submenu: "Keyboard"
|
||||||
gamepad_submenu: "Gamepad"
|
gamepad_submenu: "Gamepad"
|
||||||
|
tweaks_submenu: "Gameplay"
|
||||||
system_submenu: "System"
|
system_submenu: "System"
|
||||||
move_up: "Up"
|
move_up: "Up"
|
||||||
move_down: "Down"
|
move_down: "Down"
|
||||||
@@ -100,6 +115,9 @@ menu:
|
|||||||
music_volume: "Music volume"
|
music_volume: "Music volume"
|
||||||
sounds: "Sounds"
|
sounds: "Sounds"
|
||||||
sounds_volume: "Sound volume"
|
sounds_volume: "Sound volume"
|
||||||
|
whip_pierces_walls: "Whip pierces sarcs"
|
||||||
|
friendly_fire: "Friendly fire"
|
||||||
|
players_collide: "Player collision"
|
||||||
restart: "Restart"
|
restart: "Restart"
|
||||||
exit_game: "Exit game"
|
exit_game: "Exit game"
|
||||||
values:
|
values:
|
||||||
|
|||||||
@@ -46,3 +46,48 @@ text_panel:
|
|||||||
color: [36, 182, 170]
|
color: [36, 182, 170]
|
||||||
alpha: 128
|
alpha: 128
|
||||||
height_px: 32
|
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
|
||||||
@@ -113,6 +113,13 @@ void Audio::stopMusic() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NOLINTNEXTLINE(readability-make-member-function-const)
|
||||||
|
void Audio::setMusicSpeed(float ratio) {
|
||||||
|
if (music_enabled_) {
|
||||||
|
engine_->setMusicSpeed(ratio);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Reprodueix un so per nom
|
// Reprodueix un so per nom
|
||||||
// NOLINTNEXTLINE(readability-make-member-function-const) — muta estat via engine_; unique_ptr::operator-> no propaga const
|
// NOLINTNEXTLINE(readability-make-member-function-const) — muta estat via engine_; unique_ptr::operator-> no propaga const
|
||||||
void Audio::playSound(const std::string& name, Group group) {
|
void Audio::playSound(const std::string& name, Group group) {
|
||||||
|
|||||||
@@ -76,6 +76,11 @@ class Audio {
|
|||||||
void stopMusic(); // Aturar completament la música
|
void stopMusic(); // Aturar completament la música
|
||||||
void fadeOutMusic(int milliseconds); // Fosa de sortida de la música (muta globals de Ja)
|
void fadeOutMusic(int milliseconds); // Fosa de sortida de la música (muta globals de Ja)
|
||||||
void setOnMusicEnded(std::function<void()> callback); // Callback disparat quan la pista actual acaba de drenar (CONV-03)
|
void setOnMusicEnded(std::function<void()> callback); // Callback disparat quan la pista actual acaba de drenar (CONV-03)
|
||||||
|
// Multiplicador de velocitat de la música actual. 1.0 = normal,
|
||||||
|
// 1.5 = un 50% més ràpid (efecte "chipmunk" — també puja el to).
|
||||||
|
// Es reseteja a 1.0 implícitament a cada `playMusic`. No-op si no
|
||||||
|
// hi ha música activa.
|
||||||
|
void setMusicSpeed(float ratio);
|
||||||
|
|
||||||
// --- Control de sons ---
|
// --- Control de sons ---
|
||||||
void playSound(const std::string& name, Group group = Group::GAME); // Reproduir so puntual per nom (muta globals de Ja)
|
void playSound(const std::string& name, Group group = Group::GAME); // Reproduir so puntual per nom (muta globals de Ja)
|
||||||
|
|||||||
@@ -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() {
|
void Engine::pauseMusic() {
|
||||||
if (current_music_ == nullptr || current_music_->state != MusicState::PLAYING) { return; }
|
if (current_music_ == nullptr || current_music_->state != MusicState::PLAYING) { return; }
|
||||||
|
|
||||||
|
|||||||
@@ -171,6 +171,14 @@ namespace Ja {
|
|||||||
void crossfadeMusic(Music* music, int crossfade_ms, int loop = -1);
|
void crossfadeMusic(Music* music, int crossfade_ms, int loop = -1);
|
||||||
[[nodiscard]] auto getMusicState() const -> MusicState;
|
[[nodiscard]] auto getMusicState() const -> MusicState;
|
||||||
auto setMusicVolume(float volume) -> float;
|
auto setMusicVolume(float volume) -> float;
|
||||||
|
// Multiplicador de velocitat de reproducció de la música actual
|
||||||
|
// via `SDL_SetAudioStreamFrequencyRatio`. 1.0 = normal, 2.0 =
|
||||||
|
// doble velocitat. Cal saber que també puja el to (efecte
|
||||||
|
// "chipmunk") — és el comportament arcade clàssic dels comptes
|
||||||
|
// enrere. Cada `playMusic` crea un stream nou amb ratio 1.0,
|
||||||
|
// així que un canvi de track reseteja la velocitat
|
||||||
|
// implícitament. No-op si no hi ha música activa.
|
||||||
|
void setMusicSpeed(float ratio);
|
||||||
// Registra un callback que es disparà quan la música actual acabi de
|
// Registra un callback que es disparà quan la música actual acabi de
|
||||||
// drenar naturalment (times == 0 + stream buit). Es crida DESPRÉS de
|
// drenar naturalment (times == 0 + stream buit). Es crida DESPRÉS de
|
||||||
// stopMusic, així que el callback pot invocar playMusic sense córrer.
|
// stopMusic, així que el callback pot invocar playMusic sense córrer.
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
#include "core/system/event_buffer.hpp"
|
#include "core/system/event_buffer.hpp"
|
||||||
#include "core/ui/ui_config.hpp"
|
#include "core/ui/ui_config.hpp"
|
||||||
#include "game/debug_config.hpp"
|
#include "game/debug_config.hpp"
|
||||||
|
#include "game/gameplay_config.hpp"
|
||||||
#ifdef _DEBUG
|
#ifdef _DEBUG
|
||||||
#include "game/editor/editor_config.hpp"
|
#include "game/editor/editor_config.hpp"
|
||||||
#endif
|
#endif
|
||||||
@@ -69,8 +70,9 @@ Director::Director() {
|
|||||||
std::exit(1);
|
std::exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepara la carpeta de sistema (~/.config/jailgames/aee_arcade/) i carrega
|
// Prepara la carpeta de sistema (per plataforma: %APPDATA% a Windows,
|
||||||
// les opcions; si no existeix config.yaml el crea amb els defaults.
|
// ~/Library/Application Support a macOS, ~/.config a Linux) i carrega les
|
||||||
|
// opcions; si no existeix config.yaml el crea amb els defaults.
|
||||||
createSystemFolder();
|
createSystemFolder();
|
||||||
|
|
||||||
// Gamepad: descobreix mandos presents silenciosament. Cal fer-ho ABANS
|
// Gamepad: descobreix mandos presents silenciosament. Cal fer-ho ABANS
|
||||||
@@ -92,6 +94,11 @@ Director::Director() {
|
|||||||
// el patró de coffee_crisis_arcade_edition (`gamepad_configs.json`).
|
// el patró de coffee_crisis_arcade_edition (`gamepad_configs.json`).
|
||||||
GamepadBindings::get().load(system_folder_ + "gamepad_configs.yaml");
|
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
|
// Carrega els presets de PostFX des de shaders/postfx.yaml; si no existeix
|
||||||
// es crea amb presets per defecte (off, crt, ntsc).
|
// es crea amb presets per defecte (off, crt, ntsc).
|
||||||
Options::setPostFXFile(system_folder_ + Defaults::Video::POSTFX_FILE);
|
Options::setPostFXFile(system_folder_ + Defaults::Video::POSTFX_FILE);
|
||||||
@@ -161,6 +168,9 @@ Director::Director() {
|
|||||||
Resource::Cache::get().loadMusic("room_intro.ogg", Defaults::Paths::MUSIC_ROOM_INTRO);
|
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("playing.ogg", Defaults::Paths::MUSIC_PLAYING);
|
||||||
Resource::Cache::get().loadMusic("room_outro.ogg", Defaults::Paths::MUSIC_ROOM_OUTRO);
|
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
|
// Música de la TITLE: arrenca al constructor de l'escena i es fa
|
||||||
// fade-out quan l'escena entra a STARTING.
|
// fade-out quan l'escena entra a STARTING.
|
||||||
Resource::Cache::get().loadMusic("title.ogg", Defaults::Paths::MUSIC_TITLE);
|
Resource::Cache::get().loadMusic("title.ogg", Defaults::Paths::MUSIC_TITLE);
|
||||||
@@ -331,17 +341,29 @@ Director::~Director() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void Director::createSystemFolder() {
|
void Director::createSystemFolder() {
|
||||||
// Volem la config a $XDG_CONFIG_HOME/jailgames/aee_arcade/ (defecte
|
// Carpeta de configuració per plataforma:
|
||||||
// ~/.config/jailgames/aee_arcade/). SDL_GetPrefPath apunta a XDG_DATA_HOME
|
// - Windows: %APPDATA%\jailgames\aee_arcade\
|
||||||
// (.local/share), que és el lloc per a dades, no per a configuració.
|
// - macOS: ~/Library/Application Support/jailgames/aee_arcade/
|
||||||
const char* xdg = std::getenv("XDG_CONFIG_HOME");
|
// - 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;
|
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') {
|
if (xdg != nullptr && *xdg != '\0') {
|
||||||
base = xdg;
|
base = xdg;
|
||||||
} else {
|
} else {
|
||||||
const char* home = std::getenv("HOME");
|
const char* home = std::getenv("HOME");
|
||||||
base = (home != nullptr) ? std::string(home) + "/.config" : ".";
|
base = (home != nullptr) ? std::string(home) + "/.config" : ".";
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
system_folder_ = base + "/jailgames/aee_arcade/";
|
system_folder_ = base + "/jailgames/aee_arcade/";
|
||||||
|
|
||||||
std::error_code error;
|
std::error_code error;
|
||||||
|
|||||||
@@ -5,9 +5,10 @@
|
|||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
// Configuració personal de desenvolupament, carregada de `debug.yaml` dins la
|
// Configuració personal de desenvolupament, carregada de `debug.yaml` dins la
|
||||||
// carpeta de sistema (`$XDG_CONFIG_HOME/jailgames/aee_arcade/debug.yaml`). No es
|
// carpeta de sistema del joc (vegeu `Director::createSystemFolder` per la
|
||||||
// versiona: cada dev el crea a la seua màquina. Si no existeix, s'aplica el
|
// ubicació real per plataforma). No es versiona: cada dev el crea a la seua
|
||||||
// valor per defecte (GAME) sense warning de nivell error.
|
// màquina. Si no existeix, s'aplica el valor per defecte (GAME) sense warning
|
||||||
|
// de nivell error.
|
||||||
namespace DebugConfig {
|
namespace DebugConfig {
|
||||||
|
|
||||||
enum class StartScene : std::uint8_t { LOGO,
|
enum class StartScene : std::uint8_t { LOGO,
|
||||||
|
|||||||
@@ -222,9 +222,24 @@ void Mummy::step(float delta_time) {
|
|||||||
const float DX = dir_x_ * speed_ * delta_time;
|
const float DX = dir_x_ * speed_ * delta_time;
|
||||||
const float DY = dir_y_ * speed_ * delta_time;
|
const float DY = dir_y_ * speed_ * delta_time;
|
||||||
|
|
||||||
// Si no té direcció (p.ex. YAML sense `initial_direction`), el frame és
|
// Sense direcció: pot ser un setup degenerat (YAML sense
|
||||||
// no-op fins que algú la injecte amb `setDirection`.
|
// `initial_direction`) o el residu d'un HURT que va sortir amb
|
||||||
if (DX == 0.0F && DY == 0.0F) { return; }
|
// `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
|
// Branca creuament: si aquest frame travessa la línia central d'un
|
||||||
// corredor perpendicular, amarra la caixa al centre del creuament i
|
// corredor perpendicular, amarra la caixa al centre del creuament i
|
||||||
@@ -612,8 +627,20 @@ void Mummy::hurtStep(float delta_time) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Topa amb mur o sarc: surt del HURT immediatament. La IA normal
|
// Topa amb mur del playfield: amarra la BOX a la vora exacta abans de
|
||||||
// re-decideix al pròxim step (no fem U-turn ni re-orientació aquí).
|
// 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;
|
state_ = State::ROAMING;
|
||||||
hurt_remaining_s_ = 0.0F;
|
hurt_remaining_s_ = 0.0F;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#include "game/entities/entity_factory.hpp"
|
#include "game/entities/entity_factory.hpp"
|
||||||
#include "game/entities/entity_manager.hpp"
|
#include "game/entities/entity_manager.hpp"
|
||||||
#include "game/entities/player.hpp"
|
#include "game/entities/player.hpp"
|
||||||
|
#include "game/gameplay_config.hpp"
|
||||||
#include "game/map/map.hpp"
|
#include "game/map/map.hpp"
|
||||||
|
|
||||||
namespace {
|
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`.
|
// d'atac, fins a topar amb un mur o sarc, o esgotar `range_px`.
|
||||||
// L'amplada perpendicular és `tile_size` (els passadissos del joc
|
// L'amplada perpendicular és `tile_size` (els passadissos del joc
|
||||||
// tenen exactament aquesta mida i l'atac és lateral).
|
// 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 auto TILE = static_cast<float>(Map::tile_size);
|
||||||
const int MAX_PX = static_cast<int>(std::max(0.0F, range_px));
|
const int MAX_PX = static_cast<int>(std::max(0.0F, range_px));
|
||||||
const auto MAX = static_cast<float>(MAX_PX);
|
const auto MAX = static_cast<float>(MAX_PX);
|
||||||
|
const bool PIERCES = GameplayConfig::get().whipDamagePiercesWalls();
|
||||||
float length = 0.0F;
|
float length = 0.0F;
|
||||||
|
|
||||||
auto try_probe = [&](float l) -> bool {
|
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};
|
probe = {.x = spawn_x, .y = spawn_y + (MAX - l), .w = TILE, .h = l};
|
||||||
break;
|
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) {
|
for (int i = 1; i <= MAX_PX; ++i) {
|
||||||
@@ -311,6 +321,10 @@ namespace {
|
|||||||
WhipAttack::Direction dir,
|
WhipAttack::Direction dir,
|
||||||
std::unordered_set<void*>& already_hit) {
|
std::unordered_set<void*>& already_hit) {
|
||||||
if (p == owner || !playerIsTargetable(p)) { return; }
|
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; }
|
if (already_hit.contains(static_cast<void*>(p))) { return; }
|
||||||
const auto BOX = p->getBaseBox();
|
const auto BOX = p->getBaseBox();
|
||||||
if (!SDL_HasRectIntersectionFloat(&damage_rect, &BOX)) { return; }
|
if (!SDL_HasRectIntersectionFloat(&damage_rect, &BOX)) { return; }
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
#include "game/entities/entity.hpp"
|
#include "game/entities/entity.hpp"
|
||||||
#include "game/entities/entity_manager.hpp"
|
#include "game/entities/entity_manager.hpp"
|
||||||
#include "game/entities/event_bus.hpp"
|
#include "game/entities/event_bus.hpp"
|
||||||
|
#include "game/gameplay_config.hpp"
|
||||||
#include "game/options.hpp"
|
#include "game/options.hpp"
|
||||||
#include "utils/defaults.hpp"
|
#include "utils/defaults.hpp"
|
||||||
|
|
||||||
@@ -414,6 +415,10 @@ auto Player::collidesWithWorld(const SDL_FRect& box) const -> bool {
|
|||||||
// al check actor↔actor més avall via el predicat `isOutOfPlay`.
|
// al check actor↔actor més avall via el predicat `isOutOfPlay`.
|
||||||
if (isOutOfPlay()) { return false; }
|
if (isOutOfPlay()) { return false; }
|
||||||
if (map_.collides(box)) { return true; }
|
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; }
|
if (all_players_ == nullptr) { return false; }
|
||||||
// Per al contacte actor-actor usem la hit-box petita a banda i banda:
|
// 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
|
// projectem la hit-box des de la posició base proposada i la comparem
|
||||||
@@ -1027,6 +1032,12 @@ void Player::resolveLandingOverlap() {
|
|||||||
// canvi, qualsevol jugador amb el qual solape entra en HURT i
|
// canvi, qualsevol jugador amb el qual solape entra en HURT i
|
||||||
// surt projectat. Així el solapament inicial es resol per moviment
|
// surt projectat. Així el solapament inicial es resol per moviment
|
||||||
// del solapat, no per desplaçament forçat al lloc.
|
// 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; }
|
if (all_players_ == nullptr) { return; }
|
||||||
for (Player* other : *all_players_) {
|
for (Player* other : *all_players_) {
|
||||||
if (other == nullptr || other == this) { continue; }
|
if (other == nullptr || other == this) { continue; }
|
||||||
@@ -1259,11 +1270,30 @@ void Player::applyDyingMovement(float delta_time) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Player::resetForJoin() {
|
||||||
|
lives_remaining_ = death_config.initial_lives;
|
||||||
|
}
|
||||||
|
|
||||||
void Player::respawnFromDying() {
|
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_;
|
--lives_remaining_;
|
||||||
death_animation_complete_ = false;
|
death_animation_complete_ = false;
|
||||||
respawn_remaining_ms_ = 0.0F;
|
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
|
// 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
|
// dalt fins al passadís d'spawn (`spawnFromAir` reseteja primer i
|
||||||
// després posa AIRBORNE amb l'altura derivada de la geometria).
|
// després posa AIRBORNE amb l'altura derivada de la geometria).
|
||||||
@@ -1737,6 +1767,10 @@ auto Player::tryPushSnapBlockers(
|
|||||||
float (Player::*get_pos)() const,
|
float (Player::*get_pos)() const,
|
||||||
void (Player::*set_pos)(float)) -> bool {
|
void (Player::*set_pos)(float)) -> bool {
|
||||||
if (all_players_ == nullptr) { return false; }
|
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
|
// Blockers = jugadors que ara mateix solapen la nostra hit-box (ja
|
||||||
// amb el snap aplicat). Mateix criteri actor-actor que `collidesWithWorld`.
|
// amb el snap aplicat). Mateix criteri actor-actor que `collidesWithWorld`.
|
||||||
|
|||||||
@@ -127,6 +127,11 @@ class Player : public AnimatedSprite {
|
|||||||
// Ha de cridar-se abans d'instanciar Player; GameScene ho fa al ctor.
|
// Ha de cridar-se abans d'instanciar Player; GameScene ho fa al ctor.
|
||||||
static void loadFrom(const std::string& yaml_path);
|
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);
|
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
|
// 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 —
|
// `death_spinning`, posició congelada, input ignorat. Idempotent —
|
||||||
// si ja està DYING, EXITED o DISABLED, és no-op (un segon toc de
|
// 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
|
// momia mentre la roda no la reinicia). Quan l'animació acaba i
|
||||||
// `respawn_delay_ms` expira, `respawn(spawn_x_, spawn_y_)` torna a
|
// `respawn_delay_ms` expira, `respawnFromDying()` decrementa
|
||||||
// GROUNDED i `lives_remaining_` decreix en 1 (pot anar a negatius).
|
// `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);
|
void enterDying(DeathCause cause = DeathCause::GENERIC);
|
||||||
|
|
||||||
// Coordenades del spawn de respawn (cantó superior-esquerra del
|
// Coordenades del spawn de respawn (cantó superior-esquerra del
|
||||||
@@ -309,9 +317,11 @@ class Player : public AnimatedSprite {
|
|||||||
spawn_y_ = y;
|
spawn_y_ = y;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Comptador d'intents (estil Mario, NO HP). Decreix a cada respawn;
|
// 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
|
// post-mort. En arribar a 0 el jugador passa a DISABLED i publica
|
||||||
// game over. Públic per a HUD i tests.
|
// `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_; }
|
[[nodiscard]] auto getLivesRemaining() const -> int { return lives_remaining_; }
|
||||||
|
|
||||||
// Activa la immunitat per `duration_ms` ms. Mentre `isImmune()` és
|
// Activa la immunitat per `duration_ms` ms. Mentre `isImmune()` és
|
||||||
@@ -342,6 +352,14 @@ class Player : public AnimatedSprite {
|
|||||||
// sala usa `disable()` + `spawnFromAir()`.
|
// sala usa `disable()` + `spawnFromAir()`.
|
||||||
void respawn(float x, float y);
|
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
|
// Posa el jugador en el slot inactiu (DISABLED). Idempotent: la
|
||||||
// segona crida és no-op. Mateix patró que `exit()` per simetria.
|
// segona crida és no-op. Mateix patró que `exit()` per simetria.
|
||||||
// No publica res al bus — el GameScene decideix quan disparar el
|
// No publica res al bus — el GameScene decideix quan disparar el
|
||||||
@@ -829,8 +847,10 @@ class Player : public AnimatedSprite {
|
|||||||
|
|
||||||
// Comptador d'intents (estil Mario). Inicialitzat a `death_config.
|
// Comptador d'intents (estil Mario). Inicialitzat a `death_config.
|
||||||
// initial_lives` al ctor; decreix una vegada per cada respawn
|
// initial_lives` al ctor; decreix una vegada per cada respawn
|
||||||
// post-mort (no per respawns de canvi de sala). Pot anar a negatius:
|
// post-mort (no per respawns de canvi de sala). En arribar a 0,
|
||||||
// avui no hi ha game over.
|
// `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};
|
int lives_remaining_{0};
|
||||||
|
|
||||||
// Coordenades del spawn de respawn (cantó superior-esquerra del
|
// Coordenades del spawn de respawn (cantó superior-esquerra del
|
||||||
|
|||||||
@@ -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_);
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
|
};
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -32,9 +32,12 @@
|
|||||||
#include "game/entities/entity_loader.hpp"
|
#include "game/entities/entity_loader.hpp"
|
||||||
#include "game/entities/event_bus.hpp"
|
#include "game/entities/event_bus.hpp"
|
||||||
#include "game/entities/player.hpp"
|
#include "game/entities/player.hpp"
|
||||||
|
#include "game/gameplay_config.hpp"
|
||||||
#include "game/levels/level_manager.hpp"
|
#include "game/levels/level_manager.hpp"
|
||||||
#include "game/map/map.hpp"
|
#include "game/map/map.hpp"
|
||||||
#include "game/options.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/hud.hpp"
|
||||||
#include "game/ui/service_menu.hpp"
|
#include "game/ui/service_menu.hpp"
|
||||||
#include "utils/defaults.hpp"
|
#include "utils/defaults.hpp"
|
||||||
@@ -108,6 +111,11 @@ namespace {
|
|||||||
auto* a = ps[0];
|
auto* a = ps[0];
|
||||||
auto* b = ps[1];
|
auto* b = ps[1];
|
||||||
if (a == nullptr || b == nullptr) { return; }
|
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 A_AIR = a->isAirborne();
|
||||||
const bool B_AIR = b->isAirborne();
|
const bool B_AIR = b->isAirborne();
|
||||||
@@ -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_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);
|
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
|
// Carreguem assets per als DOS jugadors sempre, encara que algun no
|
||||||
// arranque actiu: l'slot existeix igualment al vector com a DISABLED
|
// arranque actiu: l'slot existeix igualment al vector com a DISABLED
|
||||||
@@ -305,6 +313,13 @@ GameScene::GameScene(Config config)
|
|||||||
entity_manager_.getBus().subscribe("player_killed", [this](const Event& ev) {
|
entity_manager_.getBus().subscribe("player_killed", [this](const Event& ev) {
|
||||||
dispatchPlayerKilled(ev.player_id);
|
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è
|
// Polvet: precarreguem el surface del Dust una sola vegada perquè
|
||||||
// els spawns runtime de l'entitat no facin disc al primer salt.
|
// els spawns runtime de l'entitat no facin disc al primer salt.
|
||||||
@@ -427,7 +442,7 @@ GameScene::GameScene(Config config)
|
|||||||
// tots els jugadors *actius* han travessat la porta. Els slots DISABLED
|
// tots els jugadors *actius* han travessat la porta. Els slots DISABLED
|
||||||
// permanents (single-player) NO compten — d'altra manera la sala mai
|
// permanents (single-player) NO compten — d'altra manera la sala mai
|
||||||
// es completaria, perquè un slot DISABLED no pot eixir per la porta.
|
// 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);
|
entity_manager_.setExpectedPlayerCount(ACTIVE_COUNT);
|
||||||
// El snap també empeny lateralment si l'altre jugador ocupa el carril:
|
// El snap també empeny lateralment si l'altre jugador ocupa el carril:
|
||||||
// passem el mateix factor perquè vaja al mateix ritme que el resolver.
|
// passem el mateix factor perquè vaja al mateix ritme que el resolver.
|
||||||
@@ -441,10 +456,17 @@ GameScene::GameScene(Config config)
|
|||||||
auto hud_font_surface = Resource::Cache::get().getSurface(HUD_FONT_GIF_KEY);
|
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);
|
auto hud_font = std::make_shared<Text>(hud_font_surface, UiConfig::get().hudFont().fnt);
|
||||||
hud_font->setDefaultKerning(UiConfig::get().hudFont().kerning);
|
hud_font->setDefaultKerning(UiConfig::get().hudFont().kerning);
|
||||||
hud_ = std::make_unique<Hud>(entity_manager_.getBus(), hud_font);
|
// Vides inicials del HUD = `initial_lives` del YAML del jugador
|
||||||
// Si hi ha P2 actiu, activem el seu bloc (per defecte el HUD naix amb
|
// (`Player::loadFrom` ja ha omplert `death_config` abans d'aquí).
|
||||||
// només P1). Mentre els events del bus no porten `player_id`, els hits
|
// Sense això, el marcador es queda fixe a 3 i el YAML perd efecte.
|
||||||
// segueixen aplicant-se a P1 (veure TODO a Hud::onPlayerHit).
|
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); }
|
if (USE_P2) { hud_->activatePlayer(2); }
|
||||||
// Sincronització inicial dels comptadors de piràmide/sala del HUD amb
|
// Sincronització inicial dels comptadors de piràmide/sala del HUD amb
|
||||||
// el LevelManager (després de cada `loadRoom` també es refresca; ací
|
// el LevelManager (després de cada `loadRoom` també es refresca; ací
|
||||||
@@ -479,6 +501,12 @@ GameScene::GameScene(Config config)
|
|||||||
// Timings i corbes de la FSM de sala. Llegits del YAML una sola
|
// Timings i corbes de la FSM de sala. Llegits del YAML una sola
|
||||||
// vegada; canvis requereixen reiniciar el joc.
|
// vegada; canvis requereixen reiniciar el joc.
|
||||||
transition_config_ = RoomFsm::loadConfig(Defaults::GameScene::GAME_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
|
// Cinemàtica d'entrada de la primera sala: arrenca la pista
|
||||||
// `room_intro.ogg` i arma el callback de fi que farà spawn + salt
|
// `room_intro.ogg` i arma el callback de fi que farà spawn + salt
|
||||||
@@ -488,7 +516,18 @@ GameScene::GameScene(Config config)
|
|||||||
enterRoomIntro();
|
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 {
|
auto GameScene::computeSpawn() -> PlayerSpawn {
|
||||||
// Centre de la columna del passadís en píxels canvas. La caixa de
|
// Centre de la columna del passadís en píxels canvas. La caixa de
|
||||||
@@ -536,7 +575,7 @@ void GameScene::loadRoom(const std::string& yaml_path) {
|
|||||||
for (auto& p : players_) { p->setMapInfo(INFO); }
|
for (auto& p : players_) { p->setMapInfo(INFO); }
|
||||||
// Tornem a fixar el comptador de "esperats" amb només els slots
|
// Tornem a fixar el comptador de "esperats" amb només els slots
|
||||||
// actius — un slot DISABLED permanent (single-player) no compta.
|
// 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);
|
entity_manager_.setExpectedPlayerCount(ACTIVE_COUNT);
|
||||||
|
|
||||||
// Tornem tots els slots a DISABLED. Els actius els reactivarà el
|
// Tornem tots els slots a DISABLED. Els actius els reactivarà el
|
||||||
@@ -623,10 +662,10 @@ void GameScene::respawnPlayersInstant() {
|
|||||||
// mateix patró de `triggerPlayerSpawn` però cridem `respawn(x, y)`
|
// mateix patró de `triggerPlayerSpawn` però cridem `respawn(x, y)`
|
||||||
// (teleport instantani) en lloc de `spawnFromAir(x, y)`.
|
// (teleport instantani) en lloc de `spawnFromAir(x, y)`.
|
||||||
const auto SPAWN = computeSpawn();
|
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);
|
const float SINGLE_X = SPAWN.p1_x + static_cast<float>(Map::tile_size);
|
||||||
for (std::size_t i = 0; i < players_.size(); ++i) {
|
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;
|
float x = SINGLE_X;
|
||||||
if (BOTH_ACTIVE) { x = (i == 0) ? SPAWN.p1_x : SPAWN.p2_x; }
|
if (BOTH_ACTIVE) { x = (i == 0) ? SPAWN.p1_x : SPAWN.p2_x; }
|
||||||
players_[i]->setSpawnPoint(x, SPAWN.y);
|
players_[i]->setSpawnPoint(x, SPAWN.y);
|
||||||
@@ -641,14 +680,14 @@ void GameScene::respawnPlayersInstant() {
|
|||||||
|
|
||||||
void GameScene::triggerPlayerSpawn(std::size_t slot_index) {
|
void GameScene::triggerPlayerSpawn(std::size_t slot_index) {
|
||||||
if (slot_index >= players_.size()) { return; }
|
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; }
|
if (players_spawned_in_intro_[slot_index]) { return; }
|
||||||
players_spawned_in_intro_[slot_index] = true;
|
players_spawned_in_intro_[slot_index] = true;
|
||||||
|
|
||||||
// Recalcula spawn per si door_col canvia entre sales (avui no, però
|
// Recalcula spawn per si door_col canvia entre sales (avui no, però
|
||||||
// la lògica ja queda preparada per a l'itinerari).
|
// la lògica ja queda preparada per a l'itinerari).
|
||||||
const auto SPAWN = computeSpawn();
|
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
|
// 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);
|
// valors de SPAWN.p1_x/p2_x estan fets per al cas BOTH (offset ±tile);
|
||||||
// amb un sol actiu, el reposicionem al centre exacte.
|
// amb un sol actiu, el reposicionem al centre exacte.
|
||||||
@@ -679,6 +718,103 @@ void GameScene::dispatchPlayerKilled(int player_id) {
|
|||||||
if (IT != players_.end()) { (*IT)->enterDying(); }
|
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() {
|
void GameScene::completeIntro() {
|
||||||
if (intro_completed_) { return; }
|
if (intro_completed_) { return; }
|
||||||
intro_completed_ = true;
|
intro_completed_ = true;
|
||||||
@@ -694,9 +830,9 @@ void GameScene::completeIntro() {
|
|||||||
|
|
||||||
void GameScene::spawnDuePlayers() {
|
void GameScene::spawnDuePlayers() {
|
||||||
if (!intro_completed_) { return; }
|
if (!intro_completed_) { return; }
|
||||||
const bool BOTH_ACTIVE = players_starts_active_[0] && players_starts_active_[1];
|
const bool BOTH_ACTIVE = players_active_[0] && players_active_[1];
|
||||||
for (std::size_t i = 0; i < players_starts_active_.size(); ++i) {
|
for (std::size_t i = 0; i < players_active_.size(); ++i) {
|
||||||
if (!players_starts_active_[i]) { continue; }
|
if (!players_active_[i]) { continue; }
|
||||||
if (players_spawned_in_intro_[i]) { continue; }
|
if (players_spawned_in_intro_[i]) { continue; }
|
||||||
// Single-player: el slot actiu (P1 o P2) sempre fa servir
|
// Single-player: el slot actiu (P1 o P2) sempre fa servir
|
||||||
// `player_spawn_delay_ms`. Mode BOTH: P1 fa player_spawn_delay_ms,
|
// `player_spawn_delay_ms`. Mode BOTH: P1 fa player_spawn_delay_ms,
|
||||||
@@ -858,12 +994,14 @@ void GameScene::renderRoomText() {
|
|||||||
const Uint8 OUTLINE = UiConfig::get().roomTextOutline();
|
const Uint8 OUTLINE = UiConfig::get().roomTextOutline();
|
||||||
const int LINE_HEIGHT = room_text_font_->getLineHeight();
|
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_ENTER = transition_config_.intro_text_enter_ms;
|
||||||
const int DUR_EXIT = transition_config_.intro_text_exit_ms;
|
const int DUR_EXIT = transition_config_.intro_text_exit_ms;
|
||||||
// Panel primer (queda sota el text gràcies a l'ordre de blits).
|
// Panel primer (queda sota el text gràcies a l'ordre de blits).
|
||||||
drawRoomTextPanel(PLAY_CENTER_Y,
|
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 auto& mgr = Levels::LevelManager::get();
|
||||||
const std::string LINE_PYRAMID = RoomFsm::formatIntroPyramidLine(mgr.currentPyramid());
|
const std::string LINE_PYRAMID = RoomFsm::formatIntroPyramidLine(mgr.currentPyramid());
|
||||||
@@ -871,12 +1009,16 @@ void GameScene::renderRoomText() {
|
|||||||
// Direccions oposades: piràmide entra per l'esquerra i surt per
|
// Direccions oposades: piràmide entra per l'esquerra i surt per
|
||||||
// la dreta; sala fa el contrari.
|
// la dreta; sala fa el contrari.
|
||||||
const int X_PYRAMID = computeRoomTextX(room_text_font_->length(LINE_PYRAMID, KERNING),
|
const int X_PYRAMID = computeRoomTextX(room_text_font_->length(LINE_PYRAMID, KERNING),
|
||||||
|
PHASE,
|
||||||
|
ELAPSED,
|
||||||
2,
|
2,
|
||||||
4,
|
4,
|
||||||
DUR_ENTER,
|
DUR_ENTER,
|
||||||
DUR_EXIT,
|
DUR_EXIT,
|
||||||
/*from_right=*/false);
|
/*from_right=*/false);
|
||||||
const int X_ROOM = computeRoomTextX(room_text_font_->length(LINE_ROOM, KERNING),
|
const int X_ROOM = computeRoomTextX(room_text_font_->length(LINE_ROOM, KERNING),
|
||||||
|
PHASE,
|
||||||
|
ELAPSED,
|
||||||
2,
|
2,
|
||||||
4,
|
4,
|
||||||
DUR_ENTER,
|
DUR_ENTER,
|
||||||
@@ -888,21 +1030,25 @@ void GameScene::renderRoomText() {
|
|||||||
const int GAP = transition_config_.intro_text_line_gap_px;
|
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_PYRAMID, X_PYRAMID, PLAY_CENTER_Y - LINE_HEIGHT - (GAP / 2), FILL, OUTLINE);
|
||||||
drawRoomTextLine(LINE_ROOM, X_ROOM, PLAY_CENTER_Y + (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_ENTER = transition_config_.outro_text_enter_ms;
|
||||||
const int DUR_EXIT = transition_config_.outro_text_exit_ms;
|
const int DUR_EXIT = transition_config_.outro_text_exit_ms;
|
||||||
drawRoomTextPanel(PLAY_CENTER_Y,
|
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_1 = Locale::get().text("game.room_complete_line1");
|
||||||
const std::string LINE_2 = Locale::get().text("game.room_complete_line2");
|
const std::string LINE_2 = Locale::get().text("game.room_complete_line2");
|
||||||
const int X_1 = computeRoomTextX(room_text_font_->length(LINE_1, KERNING),
|
const int X_1 = computeRoomTextX(room_text_font_->length(LINE_1, KERNING),
|
||||||
|
PHASE,
|
||||||
|
ELAPSED,
|
||||||
0,
|
0,
|
||||||
2,
|
2,
|
||||||
DUR_ENTER,
|
DUR_ENTER,
|
||||||
DUR_EXIT,
|
DUR_EXIT,
|
||||||
/*from_right=*/false);
|
/*from_right=*/false);
|
||||||
const int X_2 = computeRoomTextX(room_text_font_->length(LINE_2, KERNING),
|
const int X_2 = computeRoomTextX(room_text_font_->length(LINE_2, KERNING),
|
||||||
|
PHASE,
|
||||||
|
ELAPSED,
|
||||||
0,
|
0,
|
||||||
2,
|
2,
|
||||||
DUR_ENTER,
|
DUR_ENTER,
|
||||||
@@ -914,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
|
// 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
|
// 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
|
// garanteix que cau a la branca return final) i surt pel costat
|
||||||
@@ -924,16 +1070,16 @@ auto GameScene::computeRoomTextX(int width, int phase_enter, int phase_exit, int
|
|||||||
const int OFF_RIGHT = GameCanvas::WIDTH;
|
const int OFF_RIGHT = GameCanvas::WIDTH;
|
||||||
const int OFF_ENTER = from_right ? OFF_RIGHT : OFF_LEFT;
|
const int OFF_ENTER = from_right ? OFF_RIGHT : OFF_LEFT;
|
||||||
const int OFF_EXIT = from_right ? OFF_LEFT : OFF_RIGHT;
|
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)
|
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;
|
: 1.0F;
|
||||||
const auto CURVE = (transition_config_.text_enter_curve != nullptr) ? transition_config_.text_enter_curve : &Easing::linear;
|
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));
|
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)
|
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;
|
: 1.0F;
|
||||||
const auto CURVE = (transition_config_.text_exit_curve != nullptr) ? transition_config_.text_exit_curve : &Easing::linear;
|
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));
|
return Easing::lerpInt(GameCanvas::CENTER_X, OFF_EXIT, CURVE(T));
|
||||||
@@ -941,23 +1087,23 @@ auto GameScene::computeRoomTextX(int width, int phase_enter, int phase_exit, int
|
|||||||
return GameCanvas::CENTER_X;
|
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
|
// 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
|
// `text_panel_height_px` amb `text_enter_curve` durant l'enter, es
|
||||||
// manté plena durant el hold i decreix amb `text_exit_curve`
|
// manté plena durant el hold i decreix amb `text_exit_curve`
|
||||||
// durant l'exit. Fora d'aquestes tres fases retorna 0.
|
// durant l'exit. Fora d'aquestes tres fases retorna 0.
|
||||||
const int MAX = transition_config_.text_panel_height_px;
|
const int MAX = transition_config_.text_panel_height_px;
|
||||||
if (transition_.phase == phase_enter) {
|
if (phase == phase_enter) {
|
||||||
const float T = (dur_enter > 0)
|
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;
|
: 1.0F;
|
||||||
const auto CURVE = (transition_config_.text_enter_curve != nullptr) ? transition_config_.text_enter_curve : &Easing::linear;
|
const auto CURVE = (transition_config_.text_enter_curve != nullptr) ? transition_config_.text_enter_curve : &Easing::linear;
|
||||||
return Easing::lerpInt(0, MAX, CURVE(T));
|
return Easing::lerpInt(0, MAX, CURVE(T));
|
||||||
}
|
}
|
||||||
if (transition_.phase == phase_hold) { return MAX; }
|
if (phase == phase_hold) { return MAX; }
|
||||||
if (transition_.phase == phase_exit) {
|
if (phase == phase_exit) {
|
||||||
const float T = (dur_exit > 0)
|
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;
|
: 1.0F;
|
||||||
const auto CURVE = (transition_config_.text_exit_curve != nullptr) ? transition_config_.text_exit_curve : &Easing::linear;
|
const auto CURVE = (transition_config_.text_exit_curve != nullptr) ? transition_config_.text_exit_curve : &Easing::linear;
|
||||||
return Easing::lerpInt(MAX, 0, CURVE(T));
|
return Easing::lerpInt(MAX, 0, CURVE(T));
|
||||||
@@ -990,6 +1136,250 @@ void GameScene::drawRoomTextLine(const std::string& text, int x, int y, std::uin
|
|||||||
outline_color);
|
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) {
|
void GameScene::spawnDust(float feet_x_center, float feet_y, bool is_landing) {
|
||||||
// Mida del sprite de Dust llegida del seu YAML. Hardcoded per
|
// Mida del sprite de Dust llegida del seu YAML. Hardcoded per
|
||||||
// simplificar — si canvia, només cal tocar dust.yaml + aquesta
|
// simplificar — si canvia, només cal tocar dust.yaml + aquesta
|
||||||
@@ -1162,6 +1552,12 @@ void GameScene::update(int delta_ms) {
|
|||||||
// arrenque ja sigui efectiu en el mateix frame.
|
// arrenque ja sigui efectiu en el mateix frame.
|
||||||
updateRoomTransition(delta_ms);
|
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
|
#ifdef _DEBUG
|
||||||
// Toggle de l'editor: només si no hi ha cap transició de pausa ni
|
// 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
|
// pausa consolidada. L'editor mateix no consumeix la tecla — la
|
||||||
@@ -1213,6 +1609,20 @@ void GameScene::update(int delta_ms) {
|
|||||||
}
|
}
|
||||||
#endif
|
#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);
|
GlobalInputs::update(delta_ms);
|
||||||
if (GlobalInputs::wantsQuit()) { wants_quit_ = true; }
|
if (GlobalInputs::wantsQuit()) { wants_quit_ = true; }
|
||||||
|
|
||||||
@@ -1273,6 +1683,11 @@ void GameScene::update(int delta_ms) {
|
|||||||
|
|
||||||
RenderInfo::get().update(delta_ms, Options::buildRenderInfoFrame(UiConfig::get().renderInfoText(), UiConfig::get().renderInfoShadow()));
|
RenderInfo::get().update(delta_ms, Options::buildRenderInfoFrame(UiConfig::get().renderInfoText(), UiConfig::get().renderInfoShadow()));
|
||||||
Notifier::get().update(delta_ms);
|
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() {
|
void GameScene::render() {
|
||||||
@@ -1371,6 +1786,14 @@ void GameScene::render() {
|
|||||||
// Text de la transició de sala (entrada o sortida): per damunt del
|
// Text de la transició de sala (entrada o sortida): per damunt del
|
||||||
// HUD perquè és UI de transició i ha de ser sempre llegible.
|
// HUD perquè és UI de transició i ha de ser sempre llegible.
|
||||||
renderRoomText();
|
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
|
#ifdef _DEBUG
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
#include "game/entities/entity_manager.hpp"
|
#include "game/entities/entity_manager.hpp"
|
||||||
#include "game/map/paint_layer.hpp"
|
#include "game/map/paint_layer.hpp"
|
||||||
#include "game/map/tilemap.hpp"
|
#include "game/map/tilemap.hpp"
|
||||||
|
#include "game/scenes/game_over_fsm.hpp"
|
||||||
#include "game/scenes/room_fsm.hpp"
|
#include "game/scenes/room_fsm.hpp"
|
||||||
|
|
||||||
#ifdef _DEBUG
|
#ifdef _DEBUG
|
||||||
@@ -95,17 +96,19 @@ class GameScene : public Scene {
|
|||||||
// Vector des del principi: AEE suportarà multijugador local; cada Player
|
// Vector des del principi: AEE suportarà multijugador local; cada Player
|
||||||
// té els seus propis Controls i entra al vector amb una crida extra.
|
// té els seus propis Controls i entra al vector amb una crida extra.
|
||||||
// Sempre conté **dos** entrades (P1 a [0], P2 a [1]) tant en mode BOTH
|
// Sempre conté **dos** entrades (P1 a [0], P2 a [1]) tant en mode BOTH
|
||||||
// com en single-player: l'slot inactiu es queda en estat DISABLED de
|
// com en single-player: l'slot inactiu es queda en estat DISABLED i
|
||||||
// manera permanent, deixant el camí preparat per al "join in progress"
|
// pot entrar mid-partida via "join in progress" (`handleJoinInput`).
|
||||||
// futur (el segon jugador podrà entrar a meitat de partida sense
|
|
||||||
// recrear estructures). Veure `players_starts_active_`.
|
|
||||||
std::vector<std::unique_ptr<Player>> players_;
|
std::vector<std::unique_ptr<Player>> players_;
|
||||||
|
|
||||||
// Quins slots juguen aquesta partida segons `config_.starting_players`.
|
// Quins slots estan en partida ARA MATEIX (jugant o DYING). Mutable
|
||||||
// Index = id del jugador (0 = P1, 1 = P2). Determina a qui aplica el
|
// durant la sala: el ctor el sembra des de `config_.starting_players`,
|
||||||
// spawn aeri al final de la fase 0 d'INTRO; un slot amb `false` es
|
// `joinPlayer` el posa a true quan un slot inactiu pulsa START, i la
|
||||||
// queda DISABLED tot el cicle de sala.
|
// subscripció a `player_disabled` el torna a false quan un slot
|
||||||
std::array<bool, 2> players_starts_active_{false, false};
|
// esgota vides. `loadRoom` recalcula `setExpectedPlayerCount` a
|
||||||
|
// partir d'aquesta vista — un slot DISABLED al final d'una sala no
|
||||||
|
// entra automàticament a la següent (el jugador ha de re-pulsar
|
||||||
|
// START si vol re-entrar).
|
||||||
|
std::array<bool, 2> players_active_{false, false};
|
||||||
|
|
||||||
// Cinemàtica de spawn d'un slot concret. Posa el slot indicat en
|
// Cinemàtica de spawn d'un slot concret. Posa el slot indicat en
|
||||||
// AIRBORNE des d'una altura fora de pantalla. Cridat per
|
// AIRBORNE des d'una altura fora de pantalla. Cridat per
|
||||||
@@ -130,6 +133,19 @@ class GameScene : public Scene {
|
|||||||
// Extret a un mètode per acotar la complexitat cognitiva del ctor.
|
// Extret a un mètode per acotar la complexitat cognitiva del ctor.
|
||||||
void dispatchPlayerKilled(int player_id);
|
void dispatchPlayerKilled(int player_id);
|
||||||
|
|
||||||
|
// Handler de l'event `player_disabled` (un slot acaba d'esgotar
|
||||||
|
// vides). Apaga `players_active_[id]` perquè el `loadRoom` següent
|
||||||
|
// no compti aquest slot i perquè el "join in progress" del frame
|
||||||
|
// següent torni a oferir-li `setJoinPrompt(CAN_JOIN)`. Extret a
|
||||||
|
// mètode per acotar la complexitat cognitiva del subscriber/ctor.
|
||||||
|
void onPlayerDisabled(int player_id);
|
||||||
|
|
||||||
|
// Pre-load dels recursos d'àudio que s'usen mid-partida i no els
|
||||||
|
// toca el Director (so del countdown, etc.). Si la clau és buida
|
||||||
|
// o ja està al cache, és no-op. Extret a mètode per acotar la
|
||||||
|
// complexitat cognitiva del ctor.
|
||||||
|
void preloadGameOverAssets() const;
|
||||||
|
|
||||||
// Itera els slots actius i, si el delay configurat ha expirat des
|
// Itera els slots actius i, si el delay configurat ha expirat des
|
||||||
// del callback de fi de música d'INTRO, dispara `triggerPlayerSpawn`.
|
// del callback de fi de música d'INTRO, dispara `triggerPlayerSpawn`.
|
||||||
// Cridada cada frame durant la fase d'spawn escalonat (entre el
|
// Cridada cada frame durant la fase d'spawn escalonat (entre el
|
||||||
@@ -137,6 +153,33 @@ class GameScene : public Scene {
|
|||||||
// caigut tots o si encara no estem dins la finestra (intro_completed_).
|
// caigut tots o si encara no estem dins la finestra (intro_completed_).
|
||||||
void spawnDuePlayers();
|
void spawnDuePlayers();
|
||||||
|
|
||||||
|
// Atén el "join in progress" (regla 1 de la FSM de joc): si un slot
|
||||||
|
// està DISABLED i el jugador corresponent pulsa START dins de PLAYING,
|
||||||
|
// dispara `joinPlayer`. La detecció de tecla/botó la fa
|
||||||
|
// `Input::detect::pressedStart`. Ignorat fora de PLAYING (durant
|
||||||
|
// INTRO/OUTRO el slot inactiu mostra "ESPERA / PARA JUGAR" al HUD).
|
||||||
|
void handleJoinInput();
|
||||||
|
|
||||||
|
// Materialitza l'entrada d'un slot inactiu: spawn aeri al costat
|
||||||
|
// corresponent de la porta (`computeSpawn().pX_x`), reset de vides
|
||||||
|
// al valor inicial via `Player::resetForJoin`, marcatge de
|
||||||
|
// `players_active_[i]=true` i activació del bloc del HUD. La
|
||||||
|
// cinemàtica reaprofita `spawnFromAir` (mateixa caiguda visual que
|
||||||
|
// l'INTRO de sala i el respawn post-mort).
|
||||||
|
void joinPlayer(std::size_t slot_index);
|
||||||
|
|
||||||
|
// Recompte de jugadors "en partida" (no DISABLED ni EXITED, o bé
|
||||||
|
// DYING — DYING encara compta com a jugant fins que es resol). Hook
|
||||||
|
// exposat per a la futura sub-FSM CONTINUE? + GAME OVER, que vigila
|
||||||
|
// la transició a 0 jugadors actius. No s'usa dins de la Fase 1.
|
||||||
|
[[nodiscard]] auto activePlayerCount() const -> int;
|
||||||
|
|
||||||
|
// Refresca el prompt mostrat al bloc HUD de cada slot inactiu. Es
|
||||||
|
// crida cada frame des d'`update()`. La decisió viu ací (que coneix
|
||||||
|
// `state_` de la RoomFsm); el HUD només pinta. Per a slots actius,
|
||||||
|
// posa el prompt a NONE per ser defensiu davant de canvis d'estat.
|
||||||
|
void refreshJoinPrompt();
|
||||||
|
|
||||||
// Sentinel que marca el moment "música d'INTRO acabada" dins del
|
// Sentinel que marca el moment "música d'INTRO acabada" dins del
|
||||||
// timeline. Ja no ve d'un callback d'àudio: el setja el GameScene
|
// timeline. Ja no ve d'un callback d'àudio: el setja el GameScene
|
||||||
// quan la FSM ha consumit l'última fase d'INTRO (la `music_tail`,
|
// quan la FSM ha consumit l'última fase d'INTRO (la `music_tail`,
|
||||||
@@ -255,15 +298,69 @@ class GameScene : public Scene {
|
|||||||
void renderRoomFade();
|
void renderRoomFade();
|
||||||
void renderRoomText();
|
void renderRoomText();
|
||||||
|
|
||||||
// Helpers de `renderRoomText`. Tots consulten `transition_` i
|
// Configuració, estat i transició de la sub-FSM de game over
|
||||||
// `transition_config_` directament a través de `this` — així les
|
// (CONTINUE? + GAME OVER). Lligada al mateix `game.yaml` que la
|
||||||
// signatures es queden netes i la funció pública s'orquestra amb
|
// RoomFsm via `GameOverFsm::loadConfig`.
|
||||||
// poques línies. Tres tasques separades:
|
GameOverFsm::Config game_over_config_{};
|
||||||
// - X animada d'una línia (entra per un costat, surt per l'altre).
|
GameOverFsm::State game_over_state_{GameOverFsm::State::OFF};
|
||||||
// - Altura animada del panel translúcid (creix, manté, decreix).
|
GameOverFsm::Transition game_over_transition_{};
|
||||||
// - Pintat efectiu del panel i d'una línia de text.
|
|
||||||
[[nodiscard]] auto computeRoomTextX(int width, int phase_enter, int phase_exit, int dur_enter, int dur_exit, bool from_right) const -> int;
|
// Fade final cap a LOGO. Independent del `outro_fade_` perquè el
|
||||||
[[nodiscard]] auto computeRoomTextPanelHeight(int phase_enter, int phase_hold, int phase_exit, int dur_enter, int dur_exit) const -> int;
|
// game over passa per damunt de la RoomFsm i pot disparar-se durant
|
||||||
|
// PLAYING sense haver d'arrencar un OUTRO de sala. Comença a la
|
||||||
|
// fase 0 de FADE_OUT i es manté pintant alpha=255 durant el
|
||||||
|
// `black_hold` final (mateix patró que `outro_fade_`).
|
||||||
|
Transitions::FadeToBlack game_over_fade_;
|
||||||
|
bool game_over_fade_started_{false};
|
||||||
|
|
||||||
|
// Últim valor del countdown reproduït amb so. -1 com a sentinel
|
||||||
|
// perquè la primera detecció del valor inicial (`continue_count_start`)
|
||||||
|
// dispari el "tick". Es reseteja a -1 cada vegada que entrem a
|
||||||
|
// CONTINUE_PROMPT (vegeu `setGameOverState`).
|
||||||
|
int last_countdown_value_{-1};
|
||||||
|
|
||||||
|
// Avança la sub-FSM de game over consumint `delta_ms`. Detecta la
|
||||||
|
// transició de "tots els jugadors fora de partida" durant PLAYING
|
||||||
|
// (entrada a CONTINUE_PROMPT), arma el fade de FADE_OUT i, en
|
||||||
|
// arribar a DONE, demana la transició a `LOGO`.
|
||||||
|
void updateGameOverFlow(int delta_ms);
|
||||||
|
|
||||||
|
// Substitueix `setState` per a la sub-FSM de game over: log
|
||||||
|
// d'entrada/sortida i reset de la `Transition`. També arma el
|
||||||
|
// `game_over_fade_` quan entrem a FADE_OUT, en analogia amb
|
||||||
|
// `outro_fade_`.
|
||||||
|
void setGameOverState(GameOverFsm::State new_state);
|
||||||
|
|
||||||
|
// Pintat del rètol de game over (CONTINUE? + countdown durant
|
||||||
|
// CONTINUE_PROMPT; "GAME OVER" durant GAME_OVER). Reaprofita el
|
||||||
|
// panel translúcid i la font del room text.
|
||||||
|
void renderGameOverText();
|
||||||
|
|
||||||
|
// Pintat del fade negre final (FADE_OUT i black hold). Cridat
|
||||||
|
// després del HUD perquè cobreixi tota la pantalla, inclòs el
|
||||||
|
// marcador.
|
||||||
|
void renderGameOverFade();
|
||||||
|
|
||||||
|
// Comprova si dins de CONTINUE_PROMPT phase 1 el número visible
|
||||||
|
// està al rang de skip i, si un jugador pulsa salt o atac, fa
|
||||||
|
// saltar la fase al següent número via `GameOverFsm::skipToNextCountdown`.
|
||||||
|
// Extret a mètode separat per acotar la complexitat cognitiva
|
||||||
|
// d'`updateGameOverFlow`.
|
||||||
|
void handleCountdownSkip();
|
||||||
|
|
||||||
|
// Detecta canvis de número del countdown i dispara el so
|
||||||
|
// configurat un cop per cada nou valor. També extret per
|
||||||
|
// mantenir `updateGameOverFlow` per sota del llindar de tidy.
|
||||||
|
void emitCountdownTickIfChanged();
|
||||||
|
|
||||||
|
// Helpers d'animació del rètol de transició. Reutilitzats pel
|
||||||
|
// rètol de sala (`renderRoomText`) i pel de game over
|
||||||
|
// (`renderGameOverText`). Reben `phase` i `elapsed_ms` per
|
||||||
|
// paràmetre perquè la mateixa lògica visual serveix les dues
|
||||||
|
// sub-FSMs sense saber a quina pertanyen — només es lliguen al
|
||||||
|
// `transition_config_` per a corbes i geometria del panel.
|
||||||
|
[[nodiscard]] auto 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;
|
||||||
|
[[nodiscard]] auto computeRoomTextPanelHeight(int phase, int elapsed_ms, int phase_enter, int phase_hold, int phase_exit, int dur_enter, int dur_exit) const -> int;
|
||||||
void drawRoomTextPanel(int center_y, int panel_height) const;
|
void drawRoomTextPanel(int center_y, int panel_height) const;
|
||||||
void drawRoomTextLine(const std::string& text, int x, int y, std::uint8_t fill_color, std::uint8_t outline_color) const;
|
void drawRoomTextLine(const std::string& text, int x, int y, std::uint8_t fill_color, std::uint8_t outline_color) const;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
#include "game/scenes/start_input.hpp"
|
||||||
|
|
||||||
|
#include <SDL3/SDL.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
#include "core/input/gamepad_bindings.hpp"
|
||||||
|
#include "core/input/gamepad_manager.hpp"
|
||||||
|
#include "core/system/event_buffer.hpp"
|
||||||
|
#include "game/options.hpp"
|
||||||
|
#include "utils/defaults.hpp"
|
||||||
|
|
||||||
|
namespace Input::Detect {
|
||||||
|
|
||||||
|
auto pressedStart(int player_id) -> bool {
|
||||||
|
const SDL_Scancode KB = (player_id == 0) ? Defaults::KeysGame::START : Defaults::KeysGameP2::START;
|
||||||
|
SDL_Gamepad* pad = GamepadManager::get().getForPlayer(player_id);
|
||||||
|
const SDL_JoystickID PAD_ID = (pad != nullptr) ? SDL_GetGamepadID(pad) : 0;
|
||||||
|
const auto& slot = (player_id == 0) ? Options::controls().gamepad_p1
|
||||||
|
: Options::controls().gamepad_p2;
|
||||||
|
const int START_BTN = GamepadBindings::get().getButton(slot.name, slot.path, GamepadBindings::Action::START);
|
||||||
|
|
||||||
|
return std::ranges::any_of(EventBuffer::events, [&](const SDL_Event& ev) {
|
||||||
|
if (ev.type == SDL_EVENT_KEY_DOWN && ev.key.scancode == KB) { return true; }
|
||||||
|
if (ev.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN && pad != nullptr && ev.gbutton.which == PAD_ID && static_cast<int>(ev.gbutton.button) == START_BTN) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
auto pressedActionButton(int player_id) -> bool {
|
||||||
|
// Teclat: les bindings actives venen d'`Options::controls()` (les
|
||||||
|
// que el menú de servei pot reescriure), no de `Defaults` cru —
|
||||||
|
// així respectem el remapping de l'usuari igual que fa el Player.
|
||||||
|
const auto& kb = (player_id == 0) ? Options::controls().keyboard_p1
|
||||||
|
: Options::controls().keyboard_p2;
|
||||||
|
const SDL_Scancode KB_JUMP = kb.jump;
|
||||||
|
const SDL_Scancode KB_ATTACK = kb.attack;
|
||||||
|
SDL_Gamepad* pad = GamepadManager::get().getForPlayer(player_id);
|
||||||
|
const SDL_JoystickID PAD_ID = (pad != nullptr) ? SDL_GetGamepadID(pad) : 0;
|
||||||
|
const auto& slot = (player_id == 0) ? Options::controls().gamepad_p1
|
||||||
|
: Options::controls().gamepad_p2;
|
||||||
|
const int JUMP_BTN = GamepadBindings::get().getButton(slot.name, slot.path, GamepadBindings::Action::JUMP);
|
||||||
|
const int ATTACK_BTN = GamepadBindings::get().getButton(slot.name, slot.path, GamepadBindings::Action::ATTACK);
|
||||||
|
|
||||||
|
return std::ranges::any_of(EventBuffer::events, [&](const SDL_Event& ev) {
|
||||||
|
if (ev.type == SDL_EVENT_KEY_DOWN && (ev.key.scancode == KB_JUMP || ev.key.scancode == KB_ATTACK)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (ev.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN && pad != nullptr && ev.gbutton.which == PAD_ID) {
|
||||||
|
const int BTN = static_cast<int>(ev.gbutton.button);
|
||||||
|
if (BTN == JUMP_BTN || BTN == ATTACK_BTN) { return true; }
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Input::Detect
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// Helper compartit per detectar pulsacions del botó START dins del frame
|
||||||
|
// actual per a un jugador concret. Originalment vivia al namespace anònim
|
||||||
|
// de `title_scene.cpp` perquè era l'únic consumidor; ara la GameScene
|
||||||
|
// també l'usa per al "join in progress" mid-partida (regla 1 de la FSM
|
||||||
|
// del jugador), així que extraiem la funció a un namespace propi per
|
||||||
|
// evitar duplicar la pila d'input (Defaults::Keys + GamepadManager +
|
||||||
|
// Options + GamepadBindings + EventBuffer) en dos llocs.
|
||||||
|
namespace Input::Detect {
|
||||||
|
|
||||||
|
// Cert si dins dels events SDL d'aquest frame hi ha una pulsació de
|
||||||
|
// START per al jugador `player_id` (0 = P1, 1 = P2). Combina events
|
||||||
|
// de teclat (scancode hardcoded a `Defaults::KeysGame[P2]::START`) i
|
||||||
|
// de gamepad (botó remapejable via `GamepadBindings`, lligat al pad
|
||||||
|
// assignat al jugador via `Options::controls()`).
|
||||||
|
[[nodiscard]] auto pressedStart(int player_id) -> bool;
|
||||||
|
|
||||||
|
// Cert si dins dels events SDL d'aquest frame el jugador
|
||||||
|
// `player_id` (0 = P1, 1 = P2) ha pulsat el botó de salt o el
|
||||||
|
// d'atac (qualsevol dels dos). Usat per la GameScene per acceptar
|
||||||
|
// qualsevol "botó d'acció" com a senyal de skip del countdown de
|
||||||
|
// CONTINUE? un cop el número visible cau a un llindar configurable.
|
||||||
|
// Mateix patró que `pressedStart`: combina teclat (Defaults::Keys)
|
||||||
|
// i gamepad (GamepadBindings).
|
||||||
|
[[nodiscard]] auto pressedActionButton(int player_id) -> bool;
|
||||||
|
|
||||||
|
} // namespace Input::Detect
|
||||||
@@ -8,8 +8,6 @@
|
|||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
#include "core/audio/audio.hpp"
|
#include "core/audio/audio.hpp"
|
||||||
#include "core/input/gamepad_bindings.hpp"
|
|
||||||
#include "core/input/gamepad_manager.hpp"
|
|
||||||
#include "core/input/global_inputs.hpp"
|
#include "core/input/global_inputs.hpp"
|
||||||
#include "core/locale/locale.hpp"
|
#include "core/locale/locale.hpp"
|
||||||
#include "core/rendering/notifier.hpp"
|
#include "core/rendering/notifier.hpp"
|
||||||
@@ -18,11 +16,10 @@
|
|||||||
#include "core/rendering/surface.hpp"
|
#include "core/rendering/surface.hpp"
|
||||||
#include "core/rendering/text.hpp"
|
#include "core/rendering/text.hpp"
|
||||||
#include "core/resources/resource_cache.hpp"
|
#include "core/resources/resource_cache.hpp"
|
||||||
#include "core/system/event_buffer.hpp"
|
|
||||||
#include "core/ui/ui_config.hpp"
|
#include "core/ui/ui_config.hpp"
|
||||||
#include "game/options.hpp"
|
#include "game/options.hpp"
|
||||||
#include "game/scenes/scene_flow.hpp"
|
#include "game/scenes/scene_flow.hpp"
|
||||||
#include "utils/defaults.hpp"
|
#include "game/scenes/start_input.hpp"
|
||||||
#include "utils/defines.hpp"
|
#include "utils/defines.hpp"
|
||||||
#include "utils/easing.hpp"
|
#include "utils/easing.hpp"
|
||||||
#include "utils/log.hpp"
|
#include "utils/log.hpp"
|
||||||
@@ -33,27 +30,6 @@ namespace {
|
|||||||
// docs/audit-core-rendering.md).
|
// docs/audit-core-rendering.md).
|
||||||
const char* const SCENE_NAME = "TITLE";
|
const char* const SCENE_NAME = "TITLE";
|
||||||
|
|
||||||
// Detecta una pulsació de START dins el frame actual per al jugador
|
|
||||||
// indicat. Combina events de teclat (scancode hardcoded a Defaults::
|
|
||||||
// KeysGame[P2]) i events de gamepad (botó remapejable via
|
|
||||||
// GamepadBindings, lligat al pad assignat al jugador via Options).
|
|
||||||
auto pressedStart(int player_id) -> bool {
|
|
||||||
const SDL_Scancode KB = (player_id == 0) ? Defaults::KeysGame::START : Defaults::KeysGameP2::START;
|
|
||||||
SDL_Gamepad* pad = GamepadManager::get().getForPlayer(player_id);
|
|
||||||
const SDL_JoystickID PAD_ID = (pad != nullptr) ? SDL_GetGamepadID(pad) : 0;
|
|
||||||
const auto& slot = (player_id == 0) ? Options::controls().gamepad_p1
|
|
||||||
: Options::controls().gamepad_p2;
|
|
||||||
const int START_BTN = GamepadBindings::get().getButton(slot.name, slot.path, GamepadBindings::Action::START);
|
|
||||||
|
|
||||||
return std::ranges::any_of(EventBuffer::events, [&](const SDL_Event& ev) {
|
|
||||||
if (ev.type == SDL_EVENT_KEY_DOWN && ev.key.scancode == KB) { return true; }
|
|
||||||
if (ev.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN && pad != nullptr && ev.gbutton.which == PAD_ID && static_cast<int>(ev.gbutton.button) == START_BTN) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reproducció del so de START. Centralitzat ací per disparar-lo des
|
// Reproducció del so de START. Centralitzat ací per disparar-lo des
|
||||||
// dels punts on una pulsació té efecte real (entrada a STARTING o
|
// dels punts on una pulsació té efecte real (entrada a STARTING o
|
||||||
// upgrade a BOTH); l'attract timeout no l'ha de tocar.
|
// upgrade a BOTH); l'attract timeout no l'ha de tocar.
|
||||||
@@ -139,8 +115,8 @@ void TitleScene::update(int delta_ms) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const bool P1_START = pressedStart(0);
|
const bool P1_START = Input::Detect::pressedStart(0);
|
||||||
const bool P2_START = pressedStart(1);
|
const bool P2_START = Input::Detect::pressedStart(1);
|
||||||
|
|
||||||
switch (phase_) {
|
switch (phase_) {
|
||||||
case Phase::IDLE:
|
case Phase::IDLE:
|
||||||
|
|||||||
+52
-6
@@ -69,9 +69,10 @@ namespace {
|
|||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
Hud::Hud(EventBus& bus, std::shared_ptr<Text> font)
|
Hud::Hud(EventBus& bus, std::shared_ptr<Text> font, int initial_lives)
|
||||||
: bus_(bus),
|
: bus_(bus),
|
||||||
font_(std::move(font)) {
|
font_(std::move(font)),
|
||||||
|
initial_lives_(initial_lives) {
|
||||||
// Geometria fixa dels tres blocs. Sumen exactament GameCanvas::WIDTH.
|
// Geometria fixa dels tres blocs. Sumen exactament GameCanvas::WIDTH.
|
||||||
// La durada de l'animació d'entrada ve de data/config/ui.yaml via UiConfig.
|
// La durada de l'animació d'entrada ve de data/config/ui.yaml via UiConfig.
|
||||||
const int ENTRY_MS = UiConfig::get().hudEntryMs();
|
const int ENTRY_MS = UiConfig::get().hudEntryMs();
|
||||||
@@ -103,10 +104,17 @@ Hud::Hud(EventBus& bus, std::shared_ptr<Text> font)
|
|||||||
.entry_offset_y = 0,
|
.entry_offset_y = 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Per defecte, P1 actiu (sempre n'hi ha al menys un); P2 inactiu fins
|
// Naixem amb tots dos slots inactius. El GameScene crida
|
||||||
// que un segon Player s'uneix mitjançant `activatePlayer(2)`.
|
// `activatePlayer(...)` per als que arrenquen en partida segons
|
||||||
players_[0].active = true;
|
// `starting_players` (un, l'altre, o tots dos). Els no activats
|
||||||
|
// mostraran el prompt "POLSA START / PER JUGAR" via `setJoinPrompt`
|
||||||
|
// fins que el jugador faci join. Les vides s'inicialitzen al valor
|
||||||
|
// del YAML del jugador — sense això, canviar `lives:` no es
|
||||||
|
// reflectiria al marcador.
|
||||||
|
players_[0].active = false;
|
||||||
|
players_[0].lives = initial_lives_;
|
||||||
players_[1].active = false;
|
players_[1].active = false;
|
||||||
|
players_[1].lives = initial_lives_;
|
||||||
|
|
||||||
// Subscripcions al bus de l'EntityManager. De moment només `player_hit`
|
// Subscripcions al bus de l'EntityManager. De moment només `player_hit`
|
||||||
// afecta el HUD visualment: decrementa vides, dispara flash i shake al
|
// afecta el HUD visualment: decrementa vides, dispara flash i shake al
|
||||||
@@ -136,6 +144,18 @@ Hud::Hud(EventBus& bus, std::shared_ptr<Text> font)
|
|||||||
Log::info("HUD", "level_complete (mode Win pendent)");
|
Log::info("HUD", "level_complete (mode Win pendent)");
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// `player_disabled`: el jugador acaba d'esgotar les seves vides al
|
||||||
|
// final del cicle DYING. Tornem el bloc al render inactiu — el
|
||||||
|
// prompt textual el fixa GameScene cada frame via `setJoinPrompt`,
|
||||||
|
// així que ací només cal apagar `active`. Els camps lives/score/
|
||||||
|
// treasures es preserven per si en una versió futura volem mostrar
|
||||||
|
// el resultat final abans de tornar a zero.
|
||||||
|
subs_.push_back(bus_.subscribe("player_disabled", [this](const Event& ev) {
|
||||||
|
const std::size_t IDX = (ev.player_id == 1) ? 1U : 0U;
|
||||||
|
players_[IDX].active = false;
|
||||||
|
Log::info("HUD", "player_disabled p{}", IDX + 1);
|
||||||
|
}));
|
||||||
|
|
||||||
Log::info("HUD", "init: 3 blocks ready (entry {} ms)", ENTRY_MS);
|
Log::info("HUD", "init: 3 blocks ready (entry {} ms)", ENTRY_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,9 +184,15 @@ void Hud::activatePlayer(int player_one_based) {
|
|||||||
if (player_one_based < 1 || player_one_based > 2) { return; }
|
if (player_one_based < 1 || player_one_based > 2) { return; }
|
||||||
auto& p = players_[static_cast<std::size_t>(player_one_based - 1)];
|
auto& p = players_[static_cast<std::size_t>(player_one_based - 1)];
|
||||||
p.active = true;
|
p.active = true;
|
||||||
p.lives = DEFAULT_LIVES;
|
p.lives = initial_lives_;
|
||||||
p.score = 0;
|
p.score = 0;
|
||||||
p.treasures = 0;
|
p.treasures = 0;
|
||||||
|
p.join_prompt = JoinPromptMode::NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Hud::setJoinPrompt(int player_one_based, JoinPromptMode mode) {
|
||||||
|
if (player_one_based < 1 || player_one_based > 2) { return; }
|
||||||
|
players_[static_cast<std::size_t>(player_one_based - 1)].join_prompt = mode;
|
||||||
}
|
}
|
||||||
|
|
||||||
auto Hud::computeOffset(const Block& b) -> SDL_FPoint {
|
auto Hud::computeOffset(const Block& b) -> SDL_FPoint {
|
||||||
@@ -234,6 +260,26 @@ void Hud::renderBlock(BlockId id) const {
|
|||||||
|
|
||||||
void Hud::renderIdleSide(int x, int y, int w, const Block& blk, int player_index, const std::string& label) const {
|
void Hud::renderIdleSide(int x, int y, int w, const Block& blk, int player_index, const std::string& label) const {
|
||||||
const auto& p = players_[static_cast<std::size_t>(player_index)];
|
const auto& p = players_[static_cast<std::size_t>(player_index)];
|
||||||
|
|
||||||
|
// Slot inactiu amb prompt actiu: substituïm el render de zeros pel
|
||||||
|
// missatge "PULSA START / PARA JUGAR" o "ESPERA / PARA JUGAR" segons
|
||||||
|
// el mode fixat per la GameScene cada frame. El render de zeros
|
||||||
|
// queda com a fallback (mode NONE o slot actiu).
|
||||||
|
if (!p.active && p.join_prompt != JoinPromptMode::NONE) {
|
||||||
|
const bool CAN_JOIN = (p.join_prompt == JoinPromptMode::CAN_JOIN);
|
||||||
|
const std::string L1 = Locale::get().text(
|
||||||
|
CAN_JOIN ? "hud.join_prompt_can_join_l1" : "hud.join_prompt_wait_l1");
|
||||||
|
const std::string L2 = Locale::get().text(
|
||||||
|
CAN_JOIN ? "hud.join_prompt_can_join_l2" : "hud.join_prompt_wait_l2");
|
||||||
|
const int L1_W = font_->length(L1);
|
||||||
|
const int L2_W = font_->length(L2);
|
||||||
|
const int X1 = x + ((w - L1_W) / 2);
|
||||||
|
const int X2 = x + ((w - L2_W) / 2);
|
||||||
|
font_->writeColored(X1, y + LINE1_Y, L1, UiConfig::get().hudLabel());
|
||||||
|
font_->writeColored(X2, y + LINE2_Y, L2, UiConfig::get().hudValue());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const Uint8 SCORE_C = (blk.flash_ms_left > 0) ? UiConfig::get().hudFlash() : UiConfig::get().hudValue();
|
const Uint8 SCORE_C = (blk.flash_ms_left > 0) ? UiConfig::get().hudFlash() : UiConfig::get().hudValue();
|
||||||
|
|
||||||
// --- Línia 1: "<label>-000000" centrada ---
|
// --- Línia 1: "<label>-000000" centrada ---
|
||||||
|
|||||||
+39
-8
@@ -41,7 +41,25 @@ class Hud {
|
|||||||
WIN,
|
WIN,
|
||||||
EMPTY };
|
EMPTY };
|
||||||
|
|
||||||
Hud(EventBus& bus, std::shared_ptr<Text> font);
|
// Prompt mostrat al bloc d'un slot inactiu (en lloc dels zeros). El
|
||||||
|
// GameScene fixa aquest valor cada frame segons el `RoomFsm::State`
|
||||||
|
// perquè el missatge sigui coherent amb si el jugador pot pulsar
|
||||||
|
// START per entrar ara mateix o si ha d'esperar al canvi de fase.
|
||||||
|
// - NONE → render històric (zeros). Per a slots actius o per
|
||||||
|
// quan el GameScene no vol ensenyar prompt.
|
||||||
|
// - CAN_JOIN → "PULSA START / PARA JUGAR". Slot inactiu en PLAYING.
|
||||||
|
// - WAIT → "ESPERA / PARA JUGAR". Slot inactiu durant
|
||||||
|
// ROOM_INTRO/ROOM_OUTRO i, en fases futures, després
|
||||||
|
// de GAME OVER.
|
||||||
|
enum class JoinPromptMode : std::uint8_t { NONE,
|
||||||
|
CAN_JOIN,
|
||||||
|
WAIT };
|
||||||
|
|
||||||
|
// `initial_lives` es la cantidad de vides amb què s'activa un slot
|
||||||
|
// (P1 al ctor i, més tard, els que arriben via "join in progress").
|
||||||
|
// Ve del YAML del jugador (`Player::death_config.initial_lives`),
|
||||||
|
// així el marcador segueix automàticament la configuració del joc.
|
||||||
|
Hud(EventBus& bus, std::shared_ptr<Text> font, int initial_lives);
|
||||||
~Hud();
|
~Hud();
|
||||||
|
|
||||||
Hud(const Hud&) = delete;
|
Hud(const Hud&) = delete;
|
||||||
@@ -71,6 +89,14 @@ class Hud {
|
|||||||
// arrenca amb les vides per defecte.
|
// arrenca amb les vides per defecte.
|
||||||
void activatePlayer(int player_one_based);
|
void activatePlayer(int player_one_based);
|
||||||
|
|
||||||
|
// Fixa el prompt visible al bloc d'un slot inactiu (1-based: 1 → P1,
|
||||||
|
// 2 → P2). Cridat des de `GameScene::update()` cada frame perquè el
|
||||||
|
// missatge depèn de l'estat de la `RoomFsm` (vegeu enum
|
||||||
|
// `JoinPromptMode`). Sobre un slot actiu el prompt no es renderitza
|
||||||
|
// — el camp existeix per simetria i per evitar branques al caller.
|
||||||
|
// Idempotent: cridar amb el mateix mode és no-op.
|
||||||
|
void setJoinPrompt(int player_one_based, JoinPromptMode mode);
|
||||||
|
|
||||||
// Geometria pública perquè la GameScene puga calcular si el HUD
|
// Geometria pública perquè la GameScene puga calcular si el HUD
|
||||||
// s'està movent encara (p. ex. retardar l'spawn fins que estiga al
|
// s'està movent encara (p. ex. retardar l'spawn fins que estiga al
|
||||||
// seu lloc) — opcional, no obligatori.
|
// seu lloc) — opcional, no obligatori.
|
||||||
@@ -89,10 +115,6 @@ class Hud {
|
|||||||
static constexpr int LINE1_Y = 4;
|
static constexpr int LINE1_Y = 4;
|
||||||
static constexpr int LINE2_Y = 13;
|
static constexpr int LINE2_Y = 13;
|
||||||
|
|
||||||
// Vides inicials per jugador (placeholder fins que GameScene ho
|
|
||||||
// configure des del Config del nivell).
|
|
||||||
static constexpr int DEFAULT_LIVES = 3;
|
|
||||||
|
|
||||||
struct Block {
|
struct Block {
|
||||||
Mode mode{Mode::IDLE};
|
Mode mode{Mode::IDLE};
|
||||||
// Coordenades de pantalla on viu el bloc (constants durant la
|
// Coordenades de pantalla on viu el bloc (constants durant la
|
||||||
@@ -121,12 +143,17 @@ class Hud {
|
|||||||
};
|
};
|
||||||
|
|
||||||
struct PlayerStats {
|
struct PlayerStats {
|
||||||
int lives{DEFAULT_LIVES};
|
int lives{0};
|
||||||
int score{0};
|
int score{0};
|
||||||
int treasures{0};
|
int treasures{0};
|
||||||
// `false` → el bloc mostra `000000`/`0`/`00` (jugador inactiu).
|
// `false` → el bloc mostra el prompt actiu (`join_prompt`) o
|
||||||
// Sempre es renderitza per mantenir el layout fix.
|
// bé zeros si el prompt és NONE. Sempre es renderitza per
|
||||||
|
// mantenir el layout fix.
|
||||||
bool active{false};
|
bool active{false};
|
||||||
|
// Missatge a mostrar quan `active==false`. El GameScene el
|
||||||
|
// refresca cada frame segons l'estat de la RoomFsm (vegeu
|
||||||
|
// `Hud::setJoinPrompt`).
|
||||||
|
JoinPromptMode join_prompt{JoinPromptMode::NONE};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Pinta el bloc indicat amb el seu fons + el contingut del seu mode
|
// Pinta el bloc indicat amb el seu fons + el contingut del seu mode
|
||||||
@@ -152,4 +179,8 @@ class Hud {
|
|||||||
std::array<PlayerStats, 2> players_;
|
std::array<PlayerStats, 2> players_;
|
||||||
int pyramid_{1};
|
int pyramid_{1};
|
||||||
int room_{1};
|
int room_{1};
|
||||||
|
// Vides amb què s'arrenca un slot (`activatePlayer`). Cachejat al
|
||||||
|
// ctor des de `Player::death_config.initial_lives` per no llegir el
|
||||||
|
// singleton estàtic en el camí calent del bus de events.
|
||||||
|
int initial_lives_{0};
|
||||||
};
|
};
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
#include "core/rendering/text.hpp"
|
#include "core/rendering/text.hpp"
|
||||||
#include "core/system/event_buffer.hpp"
|
#include "core/system/event_buffer.hpp"
|
||||||
#include "core/ui/ui_config.hpp"
|
#include "core/ui/ui_config.hpp"
|
||||||
|
#include "game/gameplay_config.hpp"
|
||||||
#include "game/options.hpp"
|
#include "game/options.hpp"
|
||||||
#include "game/options_apply.hpp"
|
#include "game/options_apply.hpp"
|
||||||
#include "utils/defaults.hpp"
|
#include "utils/defaults.hpp"
|
||||||
@@ -692,6 +693,8 @@ auto ServiceMenu::buildRoot() -> Page {
|
|||||||
[]() { ServiceMenu::get().pushPage(buildAudio()); }));
|
[]() { ServiceMenu::get().pushPage(buildAudio()); }));
|
||||||
page.items.push_back(makeSubmenu("menu.items.controls_submenu",
|
page.items.push_back(makeSubmenu("menu.items.controls_submenu",
|
||||||
[]() { ServiceMenu::get().pushPage(buildControls()); }));
|
[]() { ServiceMenu::get().pushPage(buildControls()); }));
|
||||||
|
page.items.push_back(makeSubmenu("menu.items.tweaks_submenu",
|
||||||
|
[]() { ServiceMenu::get().pushPage(buildTweaks()); }));
|
||||||
page.items.push_back(makeSubmenu("menu.items.system_submenu",
|
page.items.push_back(makeSubmenu("menu.items.system_submenu",
|
||||||
[]() { ServiceMenu::get().pushPage(buildSystem()); }));
|
[]() { ServiceMenu::get().pushPage(buildSystem()); }));
|
||||||
return page;
|
return page;
|
||||||
@@ -1155,6 +1158,42 @@ void ServiceMenu::startDefineButtons(SDL_Gamepad* pad, const std::string& name,
|
|||||||
define_buttons_.enable(pad, name, path);
|
define_buttons_.enable(pad, name, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auto ServiceMenu::buildTweaks() -> Page {
|
||||||
|
// Submenú "Tweaks" — flags de jugabilitat persistits a `gameplay.yaml`
|
||||||
|
// dins la carpeta de sistema. Cada toggle muta `GameplayConfig` i
|
||||||
|
// n'invoca `save()` immediatament: l'usuari veu el canvi reflectit al
|
||||||
|
// joc sense esperar a un "aplicar" i, si tanca el joc, torna en el
|
||||||
|
// mateix estat. Tres toggles ON/OFF, sense estat addicional.
|
||||||
|
Page page;
|
||||||
|
page.title_key = "menu.titles.tweaks";
|
||||||
|
|
||||||
|
page.items.push_back(makeToggle(
|
||||||
|
"menu.items.whip_pierces_walls",
|
||||||
|
[]() { return GameplayConfig::get().whipDamagePiercesWalls(); },
|
||||||
|
[](bool v) {
|
||||||
|
GameplayConfig::get().setWhipDamagePiercesWalls(v);
|
||||||
|
GameplayConfig::get().save();
|
||||||
|
}));
|
||||||
|
|
||||||
|
page.items.push_back(makeToggle(
|
||||||
|
"menu.items.friendly_fire",
|
||||||
|
[]() { return GameplayConfig::get().friendlyFire(); },
|
||||||
|
[](bool v) {
|
||||||
|
GameplayConfig::get().setFriendlyFire(v);
|
||||||
|
GameplayConfig::get().save();
|
||||||
|
}));
|
||||||
|
|
||||||
|
page.items.push_back(makeToggle(
|
||||||
|
"menu.items.players_collide",
|
||||||
|
[]() { return GameplayConfig::get().playersCollide(); },
|
||||||
|
[](bool v) {
|
||||||
|
GameplayConfig::get().setPlayersCollide(v);
|
||||||
|
GameplayConfig::get().save();
|
||||||
|
}));
|
||||||
|
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
auto ServiceMenu::buildSystem() -> Page {
|
auto ServiceMenu::buildSystem() -> Page {
|
||||||
Page page;
|
Page page;
|
||||||
page.title_key = "menu.titles.system";
|
page.title_key = "menu.titles.system";
|
||||||
|
|||||||
@@ -253,6 +253,7 @@ class ServiceMenu {
|
|||||||
[[nodiscard]] static auto buildKeyboardControls() -> Page;
|
[[nodiscard]] static auto buildKeyboardControls() -> Page;
|
||||||
[[nodiscard]] static auto buildGamepadControls() -> Page;
|
[[nodiscard]] static auto buildGamepadControls() -> Page;
|
||||||
[[nodiscard]] static auto buildSystem() -> Page;
|
[[nodiscard]] static auto buildSystem() -> Page;
|
||||||
|
[[nodiscard]] static auto buildTweaks() -> Page;
|
||||||
|
|
||||||
// Helpers privats d'items de vídeo: aïllen la lògica de
|
// Helpers privats d'items de vídeo: aïllen la lògica de
|
||||||
// `shader_type` i `preset` fora de `buildVideo` perquè no exploti la
|
// `shader_type` i `preset` fora de `buildVideo` perquè no exploti la
|
||||||
|
|||||||
@@ -508,6 +508,7 @@ namespace Defaults {
|
|||||||
constexpr const char* MUSIC_PLAYING = "data/audio/music/playing.ogg";
|
constexpr const char* MUSIC_PLAYING = "data/audio/music/playing.ogg";
|
||||||
constexpr const char* MUSIC_ROOM_INTRO = "data/audio/music/room_intro.ogg";
|
constexpr const char* MUSIC_ROOM_INTRO = "data/audio/music/room_intro.ogg";
|
||||||
constexpr const char* MUSIC_ROOM_OUTRO = "data/audio/music/room_outro.ogg";
|
constexpr const char* MUSIC_ROOM_OUTRO = "data/audio/music/room_outro.ogg";
|
||||||
|
constexpr const char* MUSIC_GAME_OVER = "data/audio/music/game_over.ogg";
|
||||||
|
|
||||||
// Gràfics de logo / title pre-carregats pel Director.
|
// Gràfics de logo / title pre-carregats pel Director.
|
||||||
constexpr const char* LOGO_MINI_GIF = "data/scenes/logo/logo_jailgames_mini.gif";
|
constexpr const char* LOGO_MINI_GIF = "data/scenes/logo/logo_jailgames_mini.gif";
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
namespace Texts {
|
namespace Texts {
|
||||||
constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte ARCADE — JailDesigner";
|
constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte ARCADE — JailDesigner";
|
||||||
constexpr const char* COPYRIGHT = "© 2026 JailDesigner";
|
constexpr const char* COPYRIGHT = "© 2026 JailDesigner";
|
||||||
constexpr const char* VERSION = "0.37.0"; // Single source of truth — Makefile el grep
|
constexpr const char* VERSION = "0.40.0"; // Single source of truth — Makefile el grep
|
||||||
} // namespace Texts
|
} // namespace Texts
|
||||||
|
|
||||||
namespace GameCanvas {
|
namespace GameCanvas {
|
||||||
|
|||||||
@@ -496,6 +496,99 @@ namespace {
|
|||||||
REQUIRE(mummy.getState() == Mummy::State::ROAMING);
|
REQUIRE(mummy.getState() == Mummy::State::ROAMING);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void testMummyHurtSnapsAtPlayfieldWall() {
|
||||||
|
std::cout << "[T18] mummy HURT amarra a la vora del playfield en topar\n";
|
||||||
|
// Regressió: amb dt no-exacte (joc real → SDL_GetTicks ms) el drift
|
||||||
|
// sub-pixel de la cursa HURT deixava la BOX a 0.x px dins de l'última
|
||||||
|
// cel·la quan topava amb el mur. Com que la BOX 16×16 cabe exacta al
|
||||||
|
// corredor 16×16, qualsevol drift fa que la cantonada perpendicular
|
||||||
|
// 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
|
||||||
|
// amb l'animació de córrer activa. El fix és snap a la vora exacta
|
||||||
|
// dins de `hurtStep` abans de transitar a ROAMING.
|
||||||
|
Map::loadFrom("data/maps/pyramid.yaml");
|
||||||
|
|
||||||
|
fkyaml::node tmpl = EntityLoader::loadTemplate("mummy");
|
||||||
|
Mummy mummy(tmpl);
|
||||||
|
// Caixa real del joc (YAML): collision 16×16 als peus del sprite 16×32.
|
||||||
|
mummy.setCollisionBox({.offset_x = 0, .offset_y = 16, .width = 16, .height = 16});
|
||||||
|
// Comencem prou a prop del mur perquè la cursa hi arribi abans que
|
||||||
|
// expiri el timer (60 px/s · 0.6 s = 36 px de runway).
|
||||||
|
mummy.setPixelRect({.x = 40.0F, .y = 160.0F, .w = 16.0F, .h = 32.0F});
|
||||||
|
REQUIRE(mummy.getCollisionRect().x == 40.0F);
|
||||||
|
|
||||||
|
mummy.enterHurt(Mummy::Direction::WEST);
|
||||||
|
REQUIRE(mummy.getState() == Mummy::State::HURT);
|
||||||
|
REQUIRE(mummy.getDirX() == -1.0F);
|
||||||
|
|
||||||
|
// Avancem amb dt no-divisor de 1 (joc real) fins que topa el mur.
|
||||||
|
for (int i = 0; i < 200 && mummy.getState() == Mummy::State::HURT; ++i) {
|
||||||
|
mummy.update(0.0173F);
|
||||||
|
}
|
||||||
|
REQUIRE(mummy.getState() == Mummy::State::ROAMING);
|
||||||
|
// El fix garanteix snap exacte a playfield_x = 24.
|
||||||
|
REQUIRE(mummy.getCollisionRect().x == static_cast<float>(Map::playfield_x));
|
||||||
|
}
|
||||||
|
|
||||||
|
void testMummyRoamingRecoversFromZeroDir() {
|
||||||
|
std::cout << "[T19] mummy ROAMING amb dir=(0,0) re-decideix la propera step()\n";
|
||||||
|
// Xarxa de seguretat: si per qualsevol via la mòmia entra a ROAMING
|
||||||
|
// amb dir_x_/dir_y_ a zero, el `step()` anterior feia early return i
|
||||||
|
// es quedava quieta per sempre. Ara re-decideix com a una intersecció
|
||||||
|
// si troba alguna direcció vàlida.
|
||||||
|
Map::loadFrom("data/maps/pyramid.yaml");
|
||||||
|
|
||||||
|
fkyaml::node tmpl = EntityLoader::loadTemplate("mummy");
|
||||||
|
Mummy mummy(tmpl);
|
||||||
|
mummy.setCollisionBox({.offset_x = 0, .offset_y = 16, .width = 16, .height = 16});
|
||||||
|
// Posicionem-la a un creuament aliniat (interior, lluny de murs i
|
||||||
|
// sarcs problemàtics): vertical x=88 ∩ horitzontal y=80.
|
||||||
|
mummy.setPixelRect({.x = 88.0F, .y = 64.0F, .w = 16.0F, .h = 32.0F});
|
||||||
|
// Forcem dir=(0,0) sense passar per HURT — simula l'estat patològic.
|
||||||
|
mummy.setDirection(0.0F, 0.0F);
|
||||||
|
REQUIRE(mummy.getDirX() == 0.0F);
|
||||||
|
REQUIRE(mummy.getDirY() == 0.0F);
|
||||||
|
|
||||||
|
// Un sol step amb dt típic ha d'assignar alguna direcció no-zero.
|
||||||
|
mummy.step(0.0167F);
|
||||||
|
const bool RECOVERED = mummy.getDirX() != 0.0F || mummy.getDirY() != 0.0F;
|
||||||
|
REQUIRE(RECOVERED);
|
||||||
|
}
|
||||||
|
|
||||||
|
void testMummyHurtCornerNoSoftLock() {
|
||||||
|
std::cout << "[T20] mummy HURT contra cantonada + re-whip no fa soft-lock\n";
|
||||||
|
// Integració: cursa HURT cap a una cantonada del playfield, després
|
||||||
|
// fueteig immediat al mateix lloc. Amb el bug, la mòmia quedava amb
|
||||||
|
// dir=(0,0) i no es movia per molts fueteigs que rebés. Amb el fix
|
||||||
|
// (snap a hurtStep + recovery a step), o bé `chooseHurtDirection`
|
||||||
|
// troba una direcció lliure des de la posició aliniada, o bé el
|
||||||
|
// proper step la rescata. En cap cas pot quedar permanentment quieta.
|
||||||
|
Map::loadFrom("data/maps/pyramid.yaml");
|
||||||
|
|
||||||
|
fkyaml::node tmpl = EntityLoader::loadTemplate("mummy");
|
||||||
|
Mummy mummy(tmpl);
|
||||||
|
mummy.setCollisionBox({.offset_x = 0, .offset_y = 16, .width = 16, .height = 16});
|
||||||
|
// Mateix runway que T18.
|
||||||
|
mummy.setPixelRect({.x = 40.0F, .y = 160.0F, .w = 16.0F, .h = 32.0F});
|
||||||
|
|
||||||
|
mummy.enterHurt(Mummy::Direction::WEST);
|
||||||
|
for (int i = 0; i < 200 && mummy.getState() == Mummy::State::HURT; ++i) {
|
||||||
|
mummy.update(0.0173F);
|
||||||
|
}
|
||||||
|
// Fueteig immediat: enterHurt al moment exacte de quedar al mur.
|
||||||
|
mummy.enterHurt(Mummy::Direction::WEST);
|
||||||
|
|
||||||
|
// Després d'una desena de frames, la mòmia s'ha de moure ja sigui via
|
||||||
|
// la fugida HURT (perpendicular trobada) o via recovery a ROAMING.
|
||||||
|
const auto BEFORE = mummy.getPixelRect();
|
||||||
|
for (int i = 0; i < 60; ++i) { mummy.update(0.0173F); }
|
||||||
|
const auto AFTER = mummy.getPixelRect();
|
||||||
|
const float DX_TOTAL = AFTER.x - BEFORE.x;
|
||||||
|
const float DY_TOTAL = AFTER.y - BEFORE.y;
|
||||||
|
REQUIRE((DX_TOTAL != 0.0F) || (DY_TOTAL != 0.0F));
|
||||||
|
}
|
||||||
|
|
||||||
void testMummyWhippableFlag() {
|
void testMummyWhippableFlag() {
|
||||||
std::cout << "[T17] mummy YAML llig `whippable` correctament\n";
|
std::cout << "[T17] mummy YAML llig `whippable` correctament\n";
|
||||||
fkyaml::node tmpl = EntityLoader::loadTemplate("mummy");
|
fkyaml::node tmpl = EntityLoader::loadTemplate("mummy");
|
||||||
@@ -584,6 +677,9 @@ auto main() -> int {
|
|||||||
testMummyStep();
|
testMummyStep();
|
||||||
testMummyHurtState();
|
testMummyHurtState();
|
||||||
testMummyHurtAgainstWall();
|
testMummyHurtAgainstWall();
|
||||||
|
testMummyHurtSnapsAtPlayfieldWall();
|
||||||
|
testMummyRoamingRecoversFromZeroDir();
|
||||||
|
testMummyHurtCornerNoSoftLock();
|
||||||
testMummyWhippableFlag();
|
testMummyWhippableFlag();
|
||||||
testTorchAnimation();
|
testTorchAnimation();
|
||||||
testExitedAggregator();
|
testExitedAggregator();
|
||||||
|
|||||||
Reference in new issue
Block a user