Merge branch 'feat/gameplay-tweaks'

Tweaks de jugabilitat persistents (gameplay.yaml a la carpeta de
sistema) amb tres flags i submenú "Jugabilitat" al ServiceMenu:
látec travessa sarcs, foc amic, col·lisió entre jugadors.
This commit is contained in:
JailDesigner committed 2026-05-08 13:43:24 +02:00
commit 1d9fea28fe
11 files changed
+328 -1

No files matched your search

+2
View File
@@ -107,6 +107,7 @@ set(APP_SOURCES
source/utils/log.cpp
source/utils/utils.cpp
source/game/debug_config.cpp
source/game/gameplay_config.cpp
source/game/options.cpp
source/game/options_apply.cpp
source/game/ui/service_menu.cpp
@@ -397,6 +398,7 @@ if(BUILD_TESTS)
tests/audio_cache_stubs.cpp
source/core/resources/resource_helper.cpp
source/core/resources/resource_pack.cpp
source/game/gameplay_config.cpp
source/game/map/map.cpp
source/utils/log.cpp
${ENTITY_SOURCES_BASE}
+5
View File
@@ -62,6 +62,7 @@ menu:
controls: "Controls"
keyboard: "Teclat"
gamepad: "Comandament"
tweaks: "Jugabilitat"
system: "Sistema"
labels:
player_1: "Jugador 1"
@@ -73,6 +74,7 @@ menu:
controls_submenu: "Controls"
keyboard_submenu: "Teclat"
gamepad_submenu: "Comandament"
tweaks_submenu: "Jugabilitat"
system_submenu: "Sistema"
move_up: "Amunt"
move_down: "Avall"
@@ -101,6 +103,9 @@ menu:
music_volume: "Volum música"
sounds: "Sons"
sounds_volume: "Volum sons"
whip_pierces_walls: "Látec travessa sarcs"
friendly_fire: "Foc amic"
players_collide: "Col·lisió entre jugadors"
restart: "Reiniciar"
exit_game: "Eixir del joc"
values:
+5
View File
@@ -61,6 +61,7 @@ menu:
controls: "Controls"
keyboard: "Keyboard"
gamepad: "Gamepad"
tweaks: "Gameplay"
system: "System"
labels:
player_1: "Player 1"
@@ -72,6 +73,7 @@ menu:
controls_submenu: "Controls"
keyboard_submenu: "Keyboard"
gamepad_submenu: "Gamepad"
tweaks_submenu: "Gameplay"
system_submenu: "System"
move_up: "Up"
move_down: "Down"
@@ -100,6 +102,9 @@ menu:
music_volume: "Music volume"
sounds: "Sounds"
sounds_volume: "Sound volume"
whip_pierces_walls: "Whip pierces sarcs"
friendly_fire: "Friendly fire"
players_collide: "Player collision"
restart: "Restart"
exit_game: "Exit game"
values:
+6
View File
@@ -29,6 +29,7 @@
#include "core/system/event_buffer.hpp"
#include "core/ui/ui_config.hpp"
#include "game/debug_config.hpp"
#include "game/gameplay_config.hpp"
#ifdef _DEBUG
#include "game/editor/editor_config.hpp"
#endif
@@ -93,6 +94,11 @@ Director::Director() {
// el patró de coffee_crisis_arcade_edition (`gamepad_configs.json`).
GamepadBindings::get().load(system_folder_ + "gamepad_configs.yaml");
// Tweaks de jugabilitat (látigo travessa sarcs, friendly fire, col·lisió
// entre jugadors). Fitxer escrivible des del menú de servei (F9). Si
// no existeix, GameplayConfig::load el crea amb els defaults.
GameplayConfig::get().load(system_folder_ + "gameplay.yaml");
// Carrega els presets de PostFX des de shaders/postfx.yaml; si no existeix
// es crea amb presets per defecte (off, crt, ntsc).
Options::setPostFXFile(system_folder_ + Defaults::Video::POSTFX_FILE);
+15 -1
View File
@@ -9,6 +9,7 @@
#include "game/entities/entity_factory.hpp"
#include "game/entities/entity_manager.hpp"
#include "game/entities/player.hpp"
#include "game/gameplay_config.hpp"
#include "game/map/map.hpp"
namespace {
@@ -174,9 +175,16 @@ void WhipAttack::computeDamageRect(Direction dir, float spawn_x, float spawn_y,
// d'atac, fins a topar amb un mur o sarc, o esgotar `range_px`.
// L'amplada perpendicular és `tile_size` (els passadissos del joc
// tenen exactament aquesta mida i l'atac és lateral).
//
// Si el tweak `whip.damage_pierces_walls` està actiu, la hitbox
// ignora els sarcòfags (passa a través del bloc complet) però
// continua respectant el `insidePlayfield`: fora de la sala no hi
// ha res a colpejar i ampliar-ho generaria visuals/edge cases
// estranys. El mur extern, doncs, sempre retalla.
const auto TILE = static_cast<float>(Map::tile_size);
const int MAX_PX = static_cast<int>(std::max(0.0F, range_px));
const auto MAX = static_cast<float>(MAX_PX);
const bool PIERCES = GameplayConfig::get().whipDamagePiercesWalls();
float length = 0.0F;
auto try_probe = [&](float l) -> bool {
@@ -198,7 +206,9 @@ void WhipAttack::computeDamageRect(Direction dir, float spawn_x, float spawn_y,
probe = {.x = spawn_x, .y = spawn_y + (MAX - l), .w = TILE, .h = l};
break;
}
return Map::insidePlayfield(probe) && !Map::collides(probe);
if (!Map::insidePlayfield(probe)) { return false; }
if (PIERCES) { return true; }
return !Map::collides(probe);
};
for (int i = 1; i <= MAX_PX; ++i) {
@@ -311,6 +321,10 @@ namespace {
WhipAttack::Direction dir,
std::unordered_set<void*>& already_hit) {
if (p == owner || !playerIsTargetable(p)) { return; }
// Tweak `combat.friendly_fire`: si està desactivat, els altres
// jugadors són immunes al látigo aliat. L'owner ja queda exclòs
// per la primera guarda; aquest filtre cobreix només cross-player.
if (!GameplayConfig::get().friendlyFire()) { return; }
if (already_hit.contains(static_cast<void*>(p))) { return; }
const auto BOX = p->getBaseBox();
if (!SDL_HasRectIntersectionFloat(&damage_rect, &BOX)) { return; }
+15
View File
@@ -22,6 +22,7 @@
#include "game/entities/entity.hpp"
#include "game/entities/entity_manager.hpp"
#include "game/entities/event_bus.hpp"
#include "game/gameplay_config.hpp"
#include "game/options.hpp"
#include "utils/defaults.hpp"
@@ -414,6 +415,10 @@ auto Player::collidesWithWorld(const SDL_FRect& box) const -> bool {
// al check actor↔actor més avall via el predicat `isOutOfPlay`.
if (isOutOfPlay()) { return false; }
if (map_.collides(box)) { return true; }
// Tweak `players.collide_with_each_other`: si està desactivat, els
// jugadors es travessen lliurement. La col·lisió contra el món
// (murs/sarcs) ja s'ha avaluat dalt; saltem només la part actor↔actor.
if (!GameplayConfig::get().playersCollide()) { return false; }
if (all_players_ == nullptr) { return false; }
// Per al contacte actor-actor usem la hit-box petita a banda i banda:
// projectem la hit-box des de la posició base proposada i la comparem
@@ -1027,6 +1032,12 @@ void Player::resolveLandingOverlap() {
// canvi, qualsevol jugador amb el qual solape entra en HURT i
// surt projectat. Així el solapament inicial es resol per moviment
// del solapat, no per desplaçament forçat al lloc.
//
// Tweak `players.collide_with_each_other`: si està desactivat, el
// saltador travessa l'altre net en lloc de projectar-lo. Aquest
// camí és independent de `collidesWithWorld`, així que cal aturar-lo
// explícitament aquí.
if (!GameplayConfig::get().playersCollide()) { return; }
if (all_players_ == nullptr) { return; }
for (Player* other : *all_players_) {
if (other == nullptr || other == this) { continue; }
@@ -1737,6 +1748,10 @@ auto Player::tryPushSnapBlockers(
float (Player::*get_pos)() const,
void (Player::*set_pos)(float)) -> bool {
if (all_players_ == nullptr) { return false; }
// Si el tweak de col·lisió entre jugadors està desactivat, mai hi
// ha res a empentar — els jugadors són intangibles entre si i el
// snap pot procedir lliurement sense desplaçar ningú.
if (!GameplayConfig::get().playersCollide()) { return false; }
// Blockers = jugadors que ara mateix solapen la nostra hit-box (ja
// amb el snap aplicat). Mateix criteri actor-actor que `collidesWithWorld`.
+163
View File
@@ -0,0 +1,163 @@
#include "game/gameplay_config.hpp"
#include <exception>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include "external/fkyaml_node.hpp"
#include "utils/log.hpp"
namespace {
auto readFile(const std::string& path) -> std::string {
std::ifstream file(path);
if (!file) { return {}; }
std::stringstream buffer;
buffer << file.rdbuf();
return buffer.str();
}
// Llig una subclau booleana obligatòria (`section.key`). Retorna
// false si la secció o la clau no hi són o el tipus no és bool —
// qualsevol d'aquests casos és una irregularitat que dispara
// reescriptura del fitxer.
auto readRequiredBool(const fkyaml::node& root, const char* section, const char* key, bool& out)
-> bool {
if (!root.contains(section)) { return false; }
const auto& sec = root[section];
if (!sec.contains(key)) { return false; }
try {
out = sec[key].get_value<bool>();
return true;
} catch (const std::exception&) {
return false;
}
}
// Escriu el fitxer amb els valors actuals + comentaris d'ajuda.
// Format estable perquè l'usuari pot editar-lo a mà.
void writeFile(const std::string& path,
int version,
bool whip_pierces,
bool friendly_fire,
bool players_collide) {
std::ofstream file(path);
if (!file) {
Log::error("gameplay.yaml", "no s'ha pogut escriure {}", path);
return;
}
file << "# gameplay.yaml — tweaks de jugabilitat persistents.\n";
file << "# Edita des del menú de servei (F9 → Tweaks) o a mà.\n";
file << "# Si esborres el fitxer (o aquesta capçalera de versió), es regenerarà\n";
file << "# amb els defaults a la propera arrencada.\n";
file << "\n";
file << "version: " << version << "\n";
file << "\n";
file << "whip:\n";
file << " damage_pierces_walls: " << (whip_pierces ? "true" : "false") << "\n";
file << "\n";
file << "combat:\n";
file << " friendly_fire: " << (friendly_fire ? "true" : "false") << "\n";
file << "\n";
file << "players:\n";
file << " collide_with_each_other: " << (players_collide ? "true" : "false") << "\n";
}
} // namespace
auto GameplayConfig::get() -> GameplayConfig& {
static GameplayConfig instance_;
return instance_;
}
void GameplayConfig::load(const std::string& file_path) {
file_path_ = file_path;
std::error_code error;
const bool EXISTS = std::filesystem::exists(file_path, error);
const auto CONTENT = EXISTS ? readFile(file_path) : std::string{};
// Tots els valors van a temporaris; només ens els quedem si la
// validació completa passa. Així una validació parcial (p. ex. dues
// claus bones i una de dolenta) no deixa l'estat a mig camí.
bool whip_pierces = whip_damage_pierces_walls_;
bool ff = friendly_fire_;
bool players_collide = players_collide_;
auto rewrite_with_defaults = [&](const char* reason) {
// Resetem els camps als defaults compilats abans de reescriure.
// Necessari si load() es crida diverses vegades amb fitxers
// distints; el primer pot haver mutat l'estat.
whip_damage_pierces_walls_ = true;
friendly_fire_ = true;
players_collide_ = true;
writeFile(file_path,
CURRENT_VERSION,
whip_damage_pierces_walls_,
friendly_fire_,
players_collide_);
Log::info("gameplay.yaml", "{} — reescrit a {} amb defaults", reason, file_path);
};
if (!EXISTS) {
rewrite_with_defaults("fitxer absent");
return;
}
if (CONTENT.empty()) {
rewrite_with_defaults("fitxer buit");
return;
}
fkyaml::node yaml;
try {
yaml = fkyaml::node::deserialize(CONTENT);
} catch (const std::exception& e) {
rewrite_with_defaults((std::string("parse error: ") + e.what()).c_str());
return;
}
// `version` ha de ser un enter present i igual a CURRENT_VERSION.
// Qualsevol altra cosa (absent, tipus no enter, valor diferent) és
// una irregularitat: el fitxer ve d'una versió incompatible o ha
// estat manipulat de manera inesperada.
if (!yaml.contains("version")) {
rewrite_with_defaults("version absent");
return;
}
int version = 0;
try {
version = yaml["version"].get_value<int>();
} catch (const std::exception&) {
rewrite_with_defaults("version no és enter");
return;
}
if (version != CURRENT_VERSION) {
rewrite_with_defaults("version desconeguda");
return;
}
if (!readRequiredBool(yaml, "whip", "damage_pierces_walls", whip_pierces) || !readRequiredBool(yaml, "combat", "friendly_fire", ff) || !readRequiredBool(yaml, "players", "collide_with_each_other", players_collide)) {
rewrite_with_defaults("clau requerida absent o de tipus incorrecte");
return;
}
// Tot validat: ens quedem els valors llegits.
whip_damage_pierces_walls_ = whip_pierces;
friendly_fire_ = ff;
players_collide_ = players_collide;
}
void GameplayConfig::save() const {
if (file_path_.empty()) {
// Sense path establert: cap consumidor s'ha registrat encara.
// Pot passar en tests que no carreguen el fitxer; silenciem el cas.
return;
}
writeFile(file_path_,
CURRENT_VERSION,
whip_damage_pierces_walls_,
friendly_fire_,
players_collide_);
}
+71
View File
@@ -0,0 +1,71 @@
#pragma once
#include <string>
// Tweaks de jugabilitat persistents per usuari. Carregat de
// `gameplay.yaml` dins la carpeta de sistema (vegeu
// `Director::createSystemFolder` per la ubicació real per plataforma).
//
// Diferent d'UiConfig (data/config/ui.yaml, només lectura): aquest fitxer
// és escrivible i el ServiceMenu en modifica els valors. Els defaults
// compilats són els que veuen els nous usuaris fins que toquen el menú.
//
// Política d'integritat: davant qualsevol irregularitat (fitxer absent,
// parse error, `version` absent o diferent, clau requerida absent o de
// tipus incorrecte), `load()` reseteja a defaults i reescriu el fitxer.
// No es preserven fitxers parcialment vàlids — així evitem dragar
// versions antigues incoherents quan l'esquema evolucioni.
class GameplayConfig {
public:
// Esquema actual del fitxer. Incrementar quan canviï l'estructura
// (renomenar/treure claus o canviar semàntica). Els fitxers d'usuari
// amb una `version` distinta es reescriuen amb els defaults nous.
// Afegir noves claus opcionals no requereix bump si els defaults
// compilats són equivalents al comportament anterior.
static constexpr int CURRENT_VERSION = 1;
static auto get() -> GameplayConfig&;
GameplayConfig(const GameplayConfig&) = delete;
GameplayConfig(GameplayConfig&&) = delete;
auto operator=(const GameplayConfig&) -> GameplayConfig& = delete;
auto operator=(GameplayConfig&&) -> GameplayConfig& = delete;
// Llig el fitxer i guarda la ruta per a `save()`. Si no existeix,
// l'escriu amb els defaults compilats. Idempotent: pot cridar-se
// múltiples vegades (l'última crida fixa el path actiu).
void load(const std::string& file_path);
// Persisteix l'estat actual al path passat a `load()`. Cridat pel
// ServiceMenu a cada canvi de toggle. No-op si `load()` no s'ha
// cridat encara (sense path no podem escriure enlloc útil).
void save() const;
// Tweaks. Defaults: tots true.
//
// - whip_damage_pierces_walls: el dany del látigo travessa sarcòfags
// (NO el mur extern del playfield, que retalla igualment per
// evitar que el rect surti de la sala visible).
// - friendly_fire: un latigazo pot impactar l'altre jugador.
// - players_collide: els jugadors col·lisionen físicament entre si
// en lloc de travessar-se.
[[nodiscard]] auto whipDamagePiercesWalls() const -> bool { return whip_damage_pierces_walls_; }
[[nodiscard]] auto friendlyFire() const -> bool { return friendly_fire_; }
[[nodiscard]] auto playersCollide() const -> bool { return players_collide_; }
void setWhipDamagePiercesWalls(bool v) { whip_damage_pierces_walls_ = v; }
void setFriendlyFire(bool v) { friendly_fire_ = v; }
void setPlayersCollide(bool v) { players_collide_ = v; }
private:
GameplayConfig() = default;
~GameplayConfig() = default;
// Ruta absoluta al fitxer persistit. Buida fins que algú crida
// `load()` per primera vegada; mentre estigui buida, `save()` és no-op.
std::string file_path_;
bool whip_damage_pierces_walls_{true};
bool friendly_fire_{true};
bool players_collide_{true};
};
+6
View File
@@ -32,6 +32,7 @@
#include "game/entities/entity_loader.hpp"
#include "game/entities/event_bus.hpp"
#include "game/entities/player.hpp"
#include "game/gameplay_config.hpp"
#include "game/levels/level_manager.hpp"
#include "game/map/map.hpp"
#include "game/options.hpp"
@@ -108,6 +109,11 @@ namespace {
auto* a = ps[0];
auto* b = ps[1];
if (a == nullptr || b == nullptr) { return; }
// Tweak `players.collide_with_each_other`: amb el flag desactivat
// saltem TOT el resolver — head-on, push i bounce aeri queden
// anul·lats. La col·lisió contra el món continua intacta perquè
// viu a `Player::collidesWithWorld`/`tryMove`, no aquí.
if (!GameplayConfig::get().playersCollide()) { return; }
const bool A_AIR = a->isAirborne();
const bool B_AIR = b->isAirborne();
+39
View File
@@ -17,6 +17,7 @@
#include "core/rendering/text.hpp"
#include "core/system/event_buffer.hpp"
#include "core/ui/ui_config.hpp"
#include "game/gameplay_config.hpp"
#include "game/options.hpp"
#include "game/options_apply.hpp"
#include "utils/defaults.hpp"
@@ -692,6 +693,8 @@ auto ServiceMenu::buildRoot() -> Page {
[]() { ServiceMenu::get().pushPage(buildAudio()); }));
page.items.push_back(makeSubmenu("menu.items.controls_submenu",
[]() { 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",
[]() { ServiceMenu::get().pushPage(buildSystem()); }));
return page;
@@ -1155,6 +1158,42 @@ void ServiceMenu::startDefineButtons(SDL_Gamepad* pad, const std::string& name,
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 {
Page page;
page.title_key = "menu.titles.system";
+1
View File
@@ -253,6 +253,7 @@ class ServiceMenu {
[[nodiscard]] static auto buildKeyboardControls() -> Page;
[[nodiscard]] static auto buildGamepadControls() -> Page;
[[nodiscard]] static auto buildSystem() -> Page;
[[nodiscard]] static auto buildTweaks() -> Page;
// Helpers privats d'items de vídeo: aïllen la lògica de
// `shader_type` i `preset` fora de `buildVideo` perquè no exploti la