From 4e9d5df872b72136437442ee4dd518f08b87c5e4 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Fri, 24 Apr 2026 14:33:52 +0200 Subject: [PATCH 01/10] feat(players): suport del segon jugador amb events amb player_id --- data/maps/pyramid.yaml | 4 +- data/player/morcus.gif | Bin 0 -> 1491 bytes data/player/morcus.yaml | 49 ++++++++++ data/tilesets/tomb.gif | Bin 5051 -> 5202 bytes source/core/system/director.cpp | 11 ++- source/game/debug_config.cpp | 54 ++++++++++- source/game/debug_config.hpp | 14 +++ source/game/entities/concrete/door.cpp | 11 ++- source/game/entities/concrete/floor_trap.cpp | 11 ++- source/game/entities/concrete/mummy.cpp | 11 ++- .../game/entities/concrete/rolling_rock.cpp | 11 ++- source/game/entities/concrete/wall_trap.cpp | 11 ++- source/game/entities/event_bus.hpp | 5 ++ source/game/entities/player.cpp | 16 +++- source/game/entities/player.hpp | 9 +- source/game/map/paint_layer.cpp | 13 ++- source/game/map/paint_layer.hpp | 2 +- source/game/scenes/game_scene.cpp | 85 ++++++++++++++---- source/game/scenes/game_scene.hpp | 9 +- source/game/ui/hud.cpp | 16 ++-- source/utils/defaults.hpp | 16 ++++ 21 files changed, 309 insertions(+), 49 deletions(-) create mode 100644 data/player/morcus.gif create mode 100644 data/player/morcus.yaml diff --git a/data/maps/pyramid.yaml b/data/maps/pyramid.yaml index 28095f6..981b1fd 100644 --- a/data/maps/pyramid.yaml +++ b/data/maps/pyramid.yaml @@ -18,8 +18,8 @@ origin: [-8, 0] # es distingeix a simple vista. Provisional fins que l'art del pintat # estigui definit; canviar aquí l'índex és tot el que cal. Omet el camp # per desactivar el pintat per a aquest jugador. -player1_paint_tile: 2 -player2_paint_tile: 2 +player1_paint_tile: 0 +player2_paint_tile: 4 # Geometria lògica de la piràmide (separada del render visual del tileset). # La graella de sarcòfags, el pitch entre ells i les amplades dels murs diff --git a/data/player/morcus.gif b/data/player/morcus.gif new file mode 100644 index 0000000000000000000000000000000000000000..d4caa57f1a7211044c5224d15ef30ca222df3577 GIT binary patch literal 1491 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 m4dhnqO6Gm$_f5`S)HnS^>p-2jyor|YesR09} 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 a$DQ0`eZ%B!-sb(&%iY}Y&0o?O1OPiL0 StartingPlayers { + if (name == "P1") { return StartingPlayers::P1; } + if (name == "P2") { return StartingPlayers::P2; } + if (name == "BOTH") { return StartingPlayers::BOTH; } + return StartingPlayers::BOTH; + } + + auto isKnownPlayers(const std::string& name) -> bool { + return name == "P1" || name == "P2" || name == "BOTH"; + } + } // namespace auto parseStartScene(const std::string& yaml_content) -> StartScene { @@ -67,6 +78,31 @@ namespace DebugConfig { return parseStartScene(CONTENT); } + auto parseStartingPlayers(const std::string& yaml_content) -> StartingPlayers { + if (yaml_content.empty()) { return StartingPlayers::BOTH; } + try { + auto yaml = fkyaml::node::deserialize(yaml_content); + if (!yaml.contains("starting_players")) { return StartingPlayers::BOTH; } + const auto RAW = yaml["starting_players"].get_value(); + if (!isKnownPlayers(RAW)) { + std::cerr << "debug.yaml: starting_players \"" << RAW << "\" invàlid, arrancant amb BOTH\n"; + return StartingPlayers::BOTH; + } + return playersFromName(RAW); + } catch (const std::exception& e) { + std::cerr << "debug.yaml: error parsejant starting_players: " << e.what() << " — arrancant amb BOTH\n"; + return StartingPlayers::BOTH; + } + } + + auto resolveStartingPlayers(const std::string& file_path) -> StartingPlayers { + std::error_code error; + if (!std::filesystem::exists(file_path, error)) { return StartingPlayers::BOTH; } + const auto CONTENT = readFile(file_path); + if (CONTENT.empty()) { return StartingPlayers::BOTH; } + return parseStartingPlayers(CONTENT); + } + auto toString(StartScene scene) -> const char* { switch (scene) { case StartScene::LOGO: @@ -81,6 +117,18 @@ namespace DebugConfig { return "GAME"; } + auto toString(StartingPlayers players) -> const char* { + switch (players) { + case StartingPlayers::P1: + return "P1"; + case StartingPlayers::P2: + return "P2"; + case StartingPlayers::BOTH: + return "BOTH"; + } + return "BOTH"; + } + void createDefaultIfMissing(const std::string& file_path) { std::error_code error; if (std::filesystem::exists(file_path, error)) { return; } @@ -90,9 +138,11 @@ namespace DebugConfig { return; } file << "# debug.yaml — ajust personal de dev (no versionat).\n"; - file << "# Valors vàlids: LOGO, TITLE, INTRO, GAME.\n"; + file << "# start_scene: LOGO | TITLE | INTRO | GAME\n"; + file << "# starting_players: P1 | P2 | BOTH\n"; file << "start_scene: GAME\n"; - std::cout << "debug.yaml: creat a " << file_path << " amb start_scene=GAME\n"; + file << "starting_players: BOTH\n"; + std::cout << "debug.yaml: creat a " << file_path << " amb start_scene=GAME starting_players=BOTH\n"; } } // namespace DebugConfig diff --git a/source/game/debug_config.hpp b/source/game/debug_config.hpp index 44dad06..182fd21 100644 --- a/source/game/debug_config.hpp +++ b/source/game/debug_config.hpp @@ -14,6 +14,13 @@ namespace DebugConfig { INTRO, GAME }; + // Quins jugadors arrenquen a la GameScene. BOTH és el cas de joc real; + // P1 i P2 són només punts d'entrada de dev per provar cada personatge + // aïllat. Llegit de `debug.yaml` (clau `starting_players`). + enum class StartingPlayers : std::uint8_t { P1, + P2, + BOTH }; + // Parseja un contingut YAML en cru. Si no conté `start_scene`, el valor és // desconegut o el YAML és invàlid, retorna GAME. Pura: no fa I/O. [[nodiscard]] auto parseStartScene(const std::string& yaml_content) -> StartScene; @@ -24,8 +31,15 @@ namespace DebugConfig { // GAME. [[nodiscard]] auto resolveStartScene(const std::string& file_path) -> StartScene; + // Paral·lel a parseStartScene / resolveStartScene per al camp + // `starting_players`. El valor per defecte (tant si el fitxer no + // existeix com si li manca la clau) és BOTH, que és el cas de joc real. + [[nodiscard]] auto parseStartingPlayers(const std::string& yaml_content) -> StartingPlayers; + [[nodiscard]] auto resolveStartingPlayers(const std::string& file_path) -> StartingPlayers; + // Nom canònic de l'enum (útil per a logs): "LOGO" / "TITLE" / "INTRO" / "GAME". [[nodiscard]] auto toString(StartScene scene) -> const char*; + [[nodiscard]] auto toString(StartingPlayers players) -> const char*; // Si el fitxer no existeix, el crea amb `start_scene: GAME` (el valor // per defecte pensat per al dia a dia de dev). Si ja existeix, no el diff --git a/source/game/entities/concrete/door.cpp b/source/game/entities/concrete/door.cpp index 3d172f2..5e0be92 100644 --- a/source/game/entities/concrete/door.cpp +++ b/source/game/entities/concrete/door.cpp @@ -5,6 +5,7 @@ #include "game/entities/entity_factory.hpp" #include "game/entities/event_bus.hpp" +#include "game/entities/player.hpp" namespace { @@ -63,9 +64,15 @@ void Door::update(float delta_time) { } } -void Door::onPlayerContact(Player* /*player*/) { +void Door::onPlayerContact(Player* player) { if (state_ != State::OPEN || level_complete_emitted_) { return; } - if (bus_ != nullptr) { bus_->publish("level_complete"); } + if (bus_ != nullptr) { + bus_->publish(Event{ + .name = "level_complete", + .payload = std::monostate{}, + .player_id = (player != nullptr) ? player->getId() : -1, + }); + } level_complete_emitted_ = true; } diff --git a/source/game/entities/concrete/floor_trap.cpp b/source/game/entities/concrete/floor_trap.cpp index 4457765..fa451f6 100644 --- a/source/game/entities/concrete/floor_trap.cpp +++ b/source/game/entities/concrete/floor_trap.cpp @@ -4,6 +4,7 @@ #include "game/entities/entity_factory.hpp" #include "game/entities/event_bus.hpp" +#include "game/entities/player.hpp" FloorTrap::FloorTrap(const fkyaml::node& yaml) { name_ = yaml.contains("name") ? yaml["name"].get_value() : std::string("floor_trap"); @@ -21,9 +22,15 @@ FloorTrap::FloorTrap(const fkyaml::node& yaml) { } } -void FloorTrap::onPlayerContact(Player* /*player*/) { +void FloorTrap::onPlayerContact(Player* player) { if (!isActive()) { return; } - if (bus_ != nullptr) { bus_->publish("player_hit", getDamage()); } + if (bus_ != nullptr) { + bus_->publish(Event{ + .name = "player_hit", + .payload = getDamage(), + .player_id = (player != nullptr) ? player->getId() : -1, + }); + } } REGISTER_ENTITY("floor_trap", FloorTrap) diff --git a/source/game/entities/concrete/mummy.cpp b/source/game/entities/concrete/mummy.cpp index 63f83c0..9128738 100644 --- a/source/game/entities/concrete/mummy.cpp +++ b/source/game/entities/concrete/mummy.cpp @@ -9,6 +9,7 @@ #include "game/entities/entity_factory.hpp" #include "game/entities/event_bus.hpp" +#include "game/entities/player.hpp" #include "game/map/map.hpp" namespace { @@ -294,8 +295,14 @@ void Mummy::applyChoice(Direction dir) { } } -void Mummy::onPlayerContact(Player* /*player*/) { - if (bus_ != nullptr) { bus_->publish("player_hit", getDamage()); } +void Mummy::onPlayerContact(Player* player) { + if (bus_ != nullptr) { + bus_->publish(Event{ + .name = "player_hit", + .payload = getDamage(), + .player_id = (player != nullptr) ? player->getId() : -1, + }); + } } REGISTER_ENTITY("mummy", Mummy) diff --git a/source/game/entities/concrete/rolling_rock.cpp b/source/game/entities/concrete/rolling_rock.cpp index 212df64..e40f7d4 100644 --- a/source/game/entities/concrete/rolling_rock.cpp +++ b/source/game/entities/concrete/rolling_rock.cpp @@ -4,6 +4,7 @@ #include "game/entities/entity_factory.hpp" #include "game/entities/event_bus.hpp" +#include "game/entities/player.hpp" #include "game/map/map.hpp" namespace { @@ -60,8 +61,14 @@ void RollingRock::step(float delta_time) { setDirection(0.0F, 0.0F); } -void RollingRock::onPlayerContact(Player* /*player*/) { - if (bus_ != nullptr) { bus_->publish("player_hit", getDamage()); } +void RollingRock::onPlayerContact(Player* player) { + if (bus_ != nullptr) { + bus_->publish(Event{ + .name = "player_hit", + .payload = getDamage(), + .player_id = (player != nullptr) ? player->getId() : -1, + }); + } } REGISTER_ENTITY("rolling_rock", RollingRock) diff --git a/source/game/entities/concrete/wall_trap.cpp b/source/game/entities/concrete/wall_trap.cpp index 43345d7..5b385f4 100644 --- a/source/game/entities/concrete/wall_trap.cpp +++ b/source/game/entities/concrete/wall_trap.cpp @@ -4,6 +4,7 @@ #include "game/entities/entity_factory.hpp" #include "game/entities/event_bus.hpp" +#include "game/entities/player.hpp" WallTrap::WallTrap(const fkyaml::node& yaml) { name_ = yaml.contains("name") ? yaml["name"].get_value() : std::string("wall_trap"); @@ -21,9 +22,15 @@ WallTrap::WallTrap(const fkyaml::node& yaml) { } } -void WallTrap::onPlayerContact(Player* /*player*/) { +void WallTrap::onPlayerContact(Player* player) { if (!isActive()) { return; } - if (bus_ != nullptr) { bus_->publish("player_hit", getDamage()); } + if (bus_ != nullptr) { + bus_->publish(Event{ + .name = "player_hit", + .payload = getDamage(), + .player_id = (player != nullptr) ? player->getId() : -1, + }); + } } REGISTER_ENTITY("wall_trap", WallTrap) diff --git a/source/game/entities/event_bus.hpp b/source/game/entities/event_bus.hpp index a61a1cf..fd7789e 100644 --- a/source/game/entities/event_bus.hpp +++ b/source/game/entities/event_bus.hpp @@ -45,6 +45,11 @@ struct Event { std::string name; using Payload = std::variant; Payload payload; + // Jugador que ha originat (o provocat) l'event. -1 = sense jugador + // associat (events del món, com `sarcophagus_opened` des de la pròpia + // entitat). Usat per al HUD (dispatch a P1/P2) i per a regles futures + // de puntuació que atribueixin accions al jugador concret. + int player_id{-1}; }; class EventBus { diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index 1c75692..db7ce2c 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -222,13 +222,19 @@ void Player::updateFacingAndTurn(float delta_time, float vx, float vy) { } } -Player::Player(const std::string& anim_key, float x, float y, const Controls& controls, const Map::Info& map_info, int paint_tile_index) +Player::Player(const std::string& anim_key, int id, float x, float y, const Controls& controls, const Map::Info& map_info, int paint_tile_index) : AnimatedSprite(Resource::Cache::get().getAnimationData(anim_key)), + id_(id), controls_(controls), map_(map_info), paint_tile_index_(paint_tile_index) { setPos(x, y); - setCurrentAnimation("stand"); + // Postura inicial mirant cap al jugador (cara avall), no de costat: + // forcem facing_ a DOWN i l'animació `stand_down` abans del primer + // frame d'input — el primer `update` la preservarà si l'usuari no + // prem cap tecla. + facing_ = Facing::DOWN; + setCurrentAnimation("stand_down"); } void Player::update(float delta_time) { @@ -308,7 +314,11 @@ void Player::update(float delta_time) { if (COL != last_tile_col_ || ROW != last_tile_row_) { last_tile_col_ = COL; last_tile_row_ = ROW; - bus_->publish("on_tile_entered", PaintedTile{.col = COL, .row = ROW, .tile_index = paint_tile_index_}); + bus_->publish(Event{ + .name = "on_tile_entered", + .payload = PaintedTile{.col = COL, .row = ROW, .tile_index = paint_tile_index_}, + .player_id = id_, + }); } } } diff --git a/source/game/entities/player.hpp b/source/game/entities/player.hpp index 89ff0d7..7109eb7 100644 --- a/source/game/entities/player.hpp +++ b/source/game/entities/player.hpp @@ -42,10 +42,16 @@ class Player : public AnimatedSprite { // Ha de cridar-se abans d'instanciar Player; GameScene ho fa al ctor. static void loadFrom(const std::string& yaml_path); - Player(const std::string& anim_key, 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); void update(float delta_time) override; + // Identificador local (0 = P1, 1 = P2…). S'usa als events del bus + // perquè els consumidors (HUD, futures regles de puntuació) sàpiguen + // quin jugador ha originat l'event. No té cap vinculació amb la + // xarxa — és únic per dins del procés. + [[nodiscard]] auto getId() const -> int { return id_; } + // Y de la base del jugador (cara inferior de la caixa de colisió). Usada // per al Z-order contra els sarcs: peus per sobre d'un sarc → darrere; // per sota → davant. @@ -104,6 +110,7 @@ class Player : public AnimatedSprite { float (Player::*get_pos)() const, void (Player::*set_pos)(float)); + int id_{-1}; Controls controls_; // Còpia plana de la geometria del mapa + punters a `collides`/ // `inside_playfield`. POD trivial; copiada al ctor i no muta durant diff --git a/source/game/map/paint_layer.cpp b/source/game/map/paint_layer.cpp index 35be755..297b629 100644 --- a/source/game/map/paint_layer.cpp +++ b/source/game/map/paint_layer.cpp @@ -60,8 +60,11 @@ PaintLayer::PaintLayer(const Tilemap& tilemap, EventBus* bus) // (sòl transitable) via `Map::isPaintable` i, si aplica, marquem // i re-publiquem `tile_painted` amb només la posició. on_tile_sub_ = bus_->subscribe("on_tile_entered", [this](const Event& ev) { + // Re-propaguem el `player_id` de qui ha trepitjat la cel·la, perquè + // `tile_painted` (que consumeixen els sarcòfags i el HUD futur) + // sàpiga quin jugador ha pintat. const auto* paint = std::get_if(&ev.payload); - if (paint != nullptr) { tryPaint(paint->col, paint->row, paint->tile_index); } + if (paint != nullptr) { tryPaint(paint->col, paint->row, paint->tile_index, ev.player_id); } }); } @@ -79,7 +82,7 @@ auto PaintLayer::isPainted(int col, int row) const -> bool { return painted_[static_cast(row)][static_cast(col)] >= 0; } -auto PaintLayer::tryPaint(int col, int row, int tile_index) -> bool { +auto PaintLayer::tryPaint(int col, int row, int tile_index, int player_id) -> bool { if (col < 0 || col >= cols_ || row < 0 || row >= rows_) { return false; } if (tile_index < 0) { return false; } if (painted_[static_cast(row)][static_cast(col)] >= 0) { @@ -92,7 +95,11 @@ auto PaintLayer::tryPaint(int col, int row, int tile_index) -> bool { blitPaintTile(col, row, tile_index); if (bus_ != nullptr) { - bus_->publish("tile_painted", TilePos{.col = col, .row = row}); + bus_->publish(Event{ + .name = "tile_painted", + .payload = TilePos{.col = col, .row = row}, + .player_id = player_id, + }); } return true; } diff --git a/source/game/map/paint_layer.hpp b/source/game/map/paint_layer.hpp index 4088ef8..d67c09e 100644 --- a/source/game/map/paint_layer.hpp +++ b/source/game/map/paint_layer.hpp @@ -44,7 +44,7 @@ class PaintLayer { // Intent de pintat directe (exposat per a tests i actors que pinten // sense passar pel bus). Retorna true si el tile és pintable i no // estava ja pintat; en aquest cas, publica `tile_painted`. - auto tryPaint(int col, int row, int tile_index) -> bool; + auto tryPaint(int col, int row, int tile_index, int player_id = -1) -> bool; [[nodiscard]] auto isPainted(int col, int row) const -> bool; diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index 34009fa..7de426f 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -69,7 +69,10 @@ auto GameScene::defaultConfig() -> Config { .map_yaml = Defaults::Pyramid::MAP_YAML, .player_sprite = Defaults::Pyramid::PLAYER_SPRITE, .player_yaml = Defaults::Pyramid::PLAYER_YAML, + .player2_sprite = Defaults::Pyramid::PLAYER_SPRITE_P2, + .player2_yaml = Defaults::Pyramid::PLAYER_YAML_P2, .music_track = Defaults::Pyramid::MUSIC_TRACK, + .starting_players = DebugConfig::StartingPlayers::BOTH, }; } @@ -78,15 +81,32 @@ GameScene::GameScene(Config config) tilemap_(makeTilemap(config_.map_yaml)) { // Convenció: el Resource::Cache indexa per basename del fitxer, perquè // la mateixa clau l'usa el parseador d'animació (YAML > tileSetFile). - const auto SPRITE_KEY = std::filesystem::path(config_.player_sprite).filename().string(); - const auto ANIM_KEY = std::filesystem::path(config_.player_yaml).filename().string(); - Resource::Cache::get().loadSurface(SPRITE_KEY, config_.player_sprite); - Resource::Cache::get().loadAnimation(ANIM_KEY, config_.player_yaml); + // Lambda perquè tenim dos conjunts d'assets (sam/morcus) i l'alta al + // cache és idèntica per als dos; la clau d'animació ens la quedem per + // passar-la al ctor del Player corresponent. + auto load_player_assets = [](const std::string& sprite, const std::string& yaml) -> std::string { + const auto SPRITE_KEY = std::filesystem::path(sprite).filename().string(); + const auto ANIM_KEY = std::filesystem::path(yaml).filename().string(); + Resource::Cache::get().loadSurface(SPRITE_KEY, sprite); + Resource::Cache::get().loadAnimation(ANIM_KEY, yaml); + return ANIM_KEY; + }; + + 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); + + std::string anim_p1; + std::string anim_p2; + if (USE_P1) { anim_p1 = load_player_assets(config_.player_sprite, config_.player_yaml); } + if (USE_P2) { anim_p2 = load_player_assets(config_.player2_sprite, config_.player2_yaml); } // Geometria de col·lisió del jugador (caixa 16×16, base 12): carregada // del mateix YAML que l'animació. Ha d'estar feta abans d'instanciar - // Player perquè el spawn consulta `collision_*`. - Player::loadFrom(config_.player_yaml); + // Player perquè el spawn consulta `collision_*`. Els membres de Player + // són `static inline`, compartits per tots els jugadors: cridem + // `loadFrom` amb el YAML del primer jugador actiu (P1 o P2). Els dos + // personatges usen la mateixa geometria (morcus és un repaletat de sam). + Player::loadFrom(USE_P1 ? config_.player_yaml : config_.player2_yaml); // Carrega les entitats del nivell (antorxa, sarcòfags, porta, momia, // etc.). Requereix que Map::loadFrom ja s'hagi cridat (ho fa @@ -97,23 +117,30 @@ GameScene::GameScene(Config config) // Subscripcions bàsiques al bus del nivell: de moment només logs, sense // tocar ni la vida del jugador ni l'estat de la escena. Serà la base // sobre la qual cablejar HUD i fi de nivell més endavant. - entity_manager_.getBus().subscribe("level_complete", [](const Event& /*ev*/) { - std::cout << "[LEVEL COMPLETE]" << '\n'; + entity_manager_.getBus().subscribe("level_complete", [](const Event& ev) { + std::cout << "[LEVEL COMPLETE] player_id=" << ev.player_id << '\n'; }); entity_manager_.getBus().subscribe("player_hit", [](const Event& ev) { const auto* dmg = std::get_if(&ev.payload); - std::cout << "[PLAYER HIT] dmg=" << (dmg != nullptr ? *dmg : 0) << '\n'; + std::cout << "[PLAYER HIT] player_id=" << ev.player_id + << " dmg=" << (dmg != nullptr ? *dmg : 0) << '\n'; }); - // Jugador 0: fletxes del teclat (Defaults::KeysGame). Instanciar-ne un - // segon només requereix afegir una altra entrada amb uns Controls - // distints (p.ex. WASD). + // Controls per jugador: P1 amb fletxes, P2 amb WASD. Cada Player + // escolta les seues pròpies tecles dins del seu `update`, no hi ha + // estat global d'input. const Player::Controls CONTROLS_P1{ .up = Defaults::KeysGame::UP, .down = Defaults::KeysGame::DOWN, .left = Defaults::KeysGame::LEFT, .right = Defaults::KeysGame::RIGHT, }; + const Player::Controls CONTROLS_P2{ + .up = Defaults::KeysGameP2::UP, + .down = Defaults::KeysGameP2::DOWN, + .left = Defaults::KeysGameP2::LEFT, + .right = Defaults::KeysGameP2::RIGHT, + }; // Spawn centrat al passadís exterior superior: la caixa de col·lisió // queda dins del passadís vertical del mig (entre columnes 1 i 2) i // centrada en Y al passadís exterior superior. Així no naix dins de @@ -123,11 +150,31 @@ GameScene::GameScene(Config config) static_cast(Map::playfield_y) + (static_cast(Map::outer_corridor) / 2.0F); const auto SPAWN_X = CORRIDOR_CENTER_X - (static_cast(Player::sprite_w) / 2.0F); const auto SPAWN_Y = CORRIDOR_CENTER_Y - static_cast(Player::collision_y_offset) - (static_cast(Player::collision_size) / 2.0F); + + // En mode BOTH separem els jugadors un tile a banda i banda del + // centre perquè no naixen superposats. En mode single-player + // (P1 o P2 aïllats per a proves de dev) l'únic jugador arrenca al + // centre exacte. + const bool BOTH = (USE_P1 && USE_P2); + const auto P1_SPAWN_X = BOTH ? SPAWN_X - static_cast(Map::tile_size) : SPAWN_X; + const auto P2_SPAWN_X = BOTH ? SPAWN_X + static_cast(Map::tile_size) : SPAWN_X; + // Índex de tile amb què pinta cada jugador — llegit del YAML del mapa // (camps opcionals `player1_paint_tile` / `player2_paint_tile`). Si no // hi són, `paint_tile_index_ = -1` i el Player no deixa rastre. - const int P1_PAINT = readOptionalInt(config_.map_yaml, "player1_paint_tile", -1); - players_.emplace_back(std::make_unique(ANIM_KEY, SPAWN_X, SPAWN_Y, CONTROLS_P1, Map::makeInfo(), P1_PAINT)); + // Ids canònics dels jugadors: 0 = P1, 1 = P2. Els events del bus els + // porten per permetre dispatch correcte al HUD i a futures regles de + // puntuació (veure `Event::player_id`). + constexpr int ID_P1 = 0; + constexpr int ID_P2 = 1; + if (USE_P1) { + const int P1_PAINT = readOptionalInt(config_.map_yaml, "player1_paint_tile", -1); + players_.emplace_back(std::make_unique(anim_p1, ID_P1, P1_SPAWN_X, SPAWN_Y, CONTROLS_P1, Map::makeInfo(), P1_PAINT)); + } + if (USE_P2) { + const int P2_PAINT = readOptionalInt(config_.map_yaml, "player2_paint_tile", -1); + players_.emplace_back(std::make_unique(anim_p2, ID_P2, P2_SPAWN_X, SPAWN_Y, CONTROLS_P2, Map::makeInfo(), P2_PAINT)); + } // PaintLayer: font única del pintat del nivell. Es crea després de // `loadFromLevel` perquè l'EntityManager ja ha de tenir els sarcòfags @@ -137,10 +184,10 @@ GameScene::GameScene(Config config) // manager per subscriure's a `on_tile_entered` i publicar `tile_painted`. paint_layer_ = std::make_unique(tilemap_, &entity_manager_.getBus()); - // Connectem el Player al bus perquè emeti `on_tile_entered` i el - // PaintLayer (subscrit ja) processe el pintat; el fan-out cap als + // Connectem tots els jugadors al bus perquè emeten `on_tile_entered`; + // el PaintLayer (subscrit ja) processa el pintat i el fan-out cap als // sarcòfags passa per `tile_painted`. - players_.back()->setEventBus(&entity_manager_.getBus()); + for (auto& p : players_) { p->setEventBus(&entity_manager_.getBus()); } // Marcador (HUD) de la franja superior. Reutilitza la font 8bithud i // la paleta "ui" ja precarregades pel Director — no toca disc des @@ -151,6 +198,10 @@ GameScene::GameScene(Config config) auto hud_font = std::make_shared(hud_font_surface, UiConfig::get().hudFont().fnt); hud_font->setDefaultKerning(UiConfig::get().hudFont().kerning); hud_ = std::make_unique(entity_manager_.getBus(), hud_font); + // Si hi ha P2 actiu, activem el seu bloc (per defecte el HUD naix amb + // només P1). Mentre els events del bus no porten `player_id`, els hits + // segueixen aplicant-se a P1 (veure TODO a Hud::onPlayerHit). + if (USE_P2) { hud_->activatePlayer(2); } // Música de fons en bucle infinit. El track ja està al Resource::Cache // (preload al Director), així que playMusic només mou el punter cap a diff --git a/source/game/scenes/game_scene.hpp b/source/game/scenes/game_scene.hpp index ccabfbc..6e4bf0a 100644 --- a/source/game/scenes/game_scene.hpp +++ b/source/game/scenes/game_scene.hpp @@ -7,6 +7,7 @@ #include "core/scene/scene.hpp" #include "core/ui/ui_config.hpp" +#include "game/debug_config.hpp" #include "game/entities/entity_manager.hpp" #include "game/map/paint_layer.hpp" #include "game/map/tilemap.hpp" @@ -22,13 +23,17 @@ class Text; class GameScene : public Scene { public: // Conjunt de rutes que defineixen una piràmide concreta (mapa + tilemap - // + sprites del jugador + música). Cada piràmide serà un Config distint; - // `defaultConfig()` retorna la primera (Defaults::Pyramid). + // + sprites dels jugadors + música). Cada piràmide serà un Config distint; + // `defaultConfig()` retorna la primera (Defaults::Pyramid). `starting_players` + // controla quins jugadors instancia el ctor: només P1, només P2 o els dos. struct Config { std::string map_yaml; std::string player_sprite; std::string player_yaml; + std::string player2_sprite; + std::string player2_yaml; std::string music_track; + DebugConfig::StartingPlayers starting_players{DebugConfig::StartingPlayers::BOTH}; }; [[nodiscard]] static auto defaultConfig() -> Config; diff --git a/source/game/ui/hud.cpp b/source/game/ui/hud.cpp index 1f1cb02..6c402c3 100644 --- a/source/game/ui/hud.cpp +++ b/source/game/ui/hud.cpp @@ -114,16 +114,20 @@ Hud::Hud(EventBus& bus, std::shared_ptr font) // i puntuacions, ampliarem aquí sense tocar callers. subs_.push_back(bus_.subscribe("player_hit", [this](const Event& ev) { const auto* dmg = std::get_if(&ev.payload); - // TODO: quan l'event porte player_id, fer dispatch al bloc real. - // Mentrestant, apliquem el hit a P1 (consistent amb el log del - // GameScene actual, que tampoc desambigua el jugador). - constexpr std::size_t IDX = 0; + // Dispatch pel `player_id` de l'event. 0 = P1 (bloc LEFT), 1 = P2 + // (bloc RIGHT). Si l'event arriba sense id (-1, event world-wide) + // fem fallback a P1 per no perdre'l — a la pràctica tots els + // publishers de `player_hit` actuals sí que porten id. + const std::size_t IDX = (ev.player_id == 1) ? 1U : 0U; + if (!players_[IDX].active) { return; } if (players_[IDX].lives > 0) { --players_[IDX].lives; } - auto& blk = blocks_[IDX]; // P1 → bloc esquerre + const std::size_t BLK_IDX = (IDX == 0) ? static_cast(BlockId::LEFT) + : static_cast(BlockId::RIGHT); + auto& blk = blocks_[BLK_IDX]; blk.flash_ms_left = UiConfig::get().hudFlashMs(); blk.shake_ms_left = UiConfig::get().hudShakeMs(); blk.shake_intensity = 1.5F; - std::cout << "[HUD] player_hit dmg=" << (dmg != nullptr ? *dmg : 0) + std::cout << "[HUD] player_hit p" << (IDX + 1) << " dmg=" << (dmg != nullptr ? *dmg : 0) << " lives=" << players_[IDX].lives << '\n'; })); diff --git a/source/utils/defaults.hpp b/source/utils/defaults.hpp index 0f49c0e..77f6a72 100644 --- a/source/utils/defaults.hpp +++ b/source/utils/defaults.hpp @@ -80,6 +80,17 @@ namespace Defaults { constexpr SDL_Scancode EXIT = SDL_SCANCODE_ESCAPE; } // namespace KeysGame + // Tecles del segon jugador local (WASD). Simètriques a KeysGame però + // mapejades a la mà esquerra del teclat. Cada Player instancia amb els + // seus propis Controls, així que aquests són un recull de fàbrica: + // canviar-los ací canvia el mapatge per a qualsevol escena que els use. + namespace KeysGameP2 { + constexpr SDL_Scancode UP = SDL_SCANCODE_W; + constexpr SDL_Scancode DOWN = SDL_SCANCODE_S; + constexpr SDL_Scancode LEFT = SDL_SCANCODE_A; + constexpr SDL_Scancode RIGHT = SDL_SCANCODE_D; + } // namespace KeysGameP2 + namespace Player { constexpr float SPEED_PX_PER_S = 60.0F; // Velocitat base del jugador (sense sprint / slow-zones). } // namespace Player @@ -209,6 +220,11 @@ namespace Defaults { constexpr const char* MAP_YAML = "data/maps/pyramid.yaml"; constexpr const char* PLAYER_SPRITE = "data/player/sam.gif"; constexpr const char* PLAYER_YAML = "data/player/sam.yaml"; + // Personatge del segon jugador. Clonat de sam amb repaletat (roig→blau); + // la geometria del sprite i les animacions coincideixen amb les de P1, + // així que Player::loadFrom només cal cridar-la amb un dels dos YAMLs. + constexpr const char* PLAYER_SPRITE_P2 = "data/player/morcus.gif"; + constexpr const char* PLAYER_YAML_P2 = "data/player/morcus.yaml"; constexpr const char* MUSIC_TRACK = "pyramid_1.ogg"; } // namespace Pyramid From a725b76684753c68d61e62c2f60f42278844bbdf Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Fri, 24 Apr 2026 15:13:44 +0200 Subject: [PATCH 02/10] =?UTF-8?q?feat(input):=20assignaci=C3=B3=20mando?= =?UTF-8?q?=E2=86=94jugador=20persistida=20a=20config.yaml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- source/core/input/gamepad_manager.cpp | 102 +++++++++++++++++++++++++- source/core/input/gamepad_manager.hpp | 55 +++++++++++++- source/game/entities/player.cpp | 15 ++-- source/game/options.cpp | 47 +++++++++++- source/game/options.hpp | 20 +++++ 5 files changed, 226 insertions(+), 13 deletions(-) diff --git a/source/core/input/gamepad_manager.cpp b/source/core/input/gamepad_manager.cpp index a3c8fe1..f9d6d2e 100644 --- a/source/core/input/gamepad_manager.cpp +++ b/source/core/input/gamepad_manager.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -87,8 +88,23 @@ void GamepadManager::addGamepad(SDL_JoystickID jid, bool notify) { return; } std::string name = uniqueName(prettyName(SDL_GetGamepadName(pad))); - std::cout << "GamepadManager: connectat " << name << '\n'; - entries_.push_back(Entry{.pad = pad, .instance_id = jid, .pretty_name = name}); + // `SDL_GetGamepadPath` pot tornar null a backends que no exposen ruta + // física (Emscripten, alguns drivers). Buit com a fallback; la + // reconnexió es resoldrà per nom. + const char* raw_path = SDL_GetGamepadPath(pad); + std::string path = (raw_path != nullptr) ? raw_path : ""; + std::cout << "GamepadManager: connectat " << name + << (path.empty() ? "" : " [" + path + "]") << '\n'; + entries_.push_back(Entry{.pad = pad, .instance_id = jid, .pretty_name = name, .path = path}); + + // Hot-plug: si alguna assignació pendent encaixa amb aquest mando, + // la vinculem ara. Primer intentem per path exacte, després per nom. + for (auto& a : assignments_) { + if (a.pad != nullptr) { continue; } + if (a.name.empty() && a.path.empty()) { continue; } + rebindAssignment(a); + } + if (notify) { Notification::notify(name + ' ' + Locale::get().text("notification.gamepad_connected")); } @@ -100,9 +116,18 @@ void GamepadManager::removeGamepad(SDL_JoystickID jid) { [jid](const Entry& e) { return e.instance_id == jid; }); if (it == entries_.end()) { return; } std::string name = std::move(it->pretty_name); - if (it->pad != nullptr) { SDL_CloseGamepad(it->pad); } + SDL_Gamepad* pad = it->pad; + if (pad != nullptr) { SDL_CloseGamepad(pad); } std::cout << "GamepadManager: desconnectat " << name << '\n'; entries_.erase(it); + + // Qualsevol assignació que apuntava a aquest mando queda amb `pad` + // a null però conservem nom/ruta: si el mando es torna a connectar, + // el hot-plug el re-vincularà automàticament. + for (auto& a : assignments_) { + if (a.pad == pad) { a.pad = nullptr; } + } + Notification::notify(name + ' ' + Locale::get().text("notification.gamepad_disconnected")); } @@ -161,3 +186,74 @@ auto GamepadManager::getCount() const -> std::size_t { auto GamepadManager::getByPlayerIndex(std::size_t idx) const -> SDL_Gamepad* { return idx < entries_.size() ? entries_[idx].pad : nullptr; } + +auto GamepadManager::getAll() const -> std::vector { + std::vector result; + result.reserve(entries_.size()); + std::ranges::transform(entries_, std::back_inserter(result), [](const Entry& e) { + return Info{.pad = e.pad, .name = e.pretty_name, .path = e.path}; + }); + return result; +} + +auto GamepadManager::getForPlayer(int player_id) const -> SDL_Gamepad* { + if (player_id < 0 || player_id >= MAX_PLAYERS) { return nullptr; } + return assignments_[static_cast(player_id)].pad; +} + +void GamepadManager::rebindAssignment(Assignment& a) { + // Primera passada: coincidència exacta per path. És el cas estable + // (mateix mando, mateix port) i no canvia res de l'assignació. + if (!a.path.empty()) { + auto it = std::ranges::find_if( + entries_, + [&](const Entry& e) { return !e.path.empty() && e.path == a.path; }); + if (it != entries_.end()) { + a.pad = it->pad; + return; + } + } + // Segona passada: coincidència per nom (mando canviat de port USB). + // Acceptem la primera entrada que no estigui ja vinculada a una altra + // assignació — així dos mandos amb el mateix nom no es pisen. + if (!a.name.empty()) { + for (const auto& e : entries_) { + if (e.pretty_name != a.name) { continue; } + const bool TAKEN = std::ranges::any_of( + assignments_, + [&](const Assignment& other) { return &other != &a && other.pad == e.pad; }); + if (TAKEN) { continue; } + a.pad = e.pad; + a.path = e.path; // actualitzem ruta persistida + return; + } + } + a.pad = nullptr; +} + +void GamepadManager::assignToPlayer(int player_id, const std::string& name, const std::string& path) { + if (player_id < 0 || player_id >= MAX_PLAYERS) { return; } + auto& a = assignments_[static_cast(player_id)]; + a.name = name; + a.path = path; + a.pad = nullptr; + if (name.empty() && path.empty()) { return; } + rebindAssignment(a); + // Last-wins: si l'altre jugador tenia el mateix mando vinculat, el + // desvinculem (conservant el seu nom/path perquè el menú pugui + // mostrar l'estat "pendent" i oferir re-selecció). + if (a.pad != nullptr) { + for (auto& other : assignments_) { + if (&other != &a && other.pad == a.pad) { + std::cout << "GamepadManager: mando " << a.name + << " reassignat (last-wins)\n"; + other.pad = nullptr; + } + } + } +} + +void GamepadManager::unassignFromPlayer(int player_id) { + if (player_id < 0 || player_id >= MAX_PLAYERS) { return; } + assignments_[static_cast(player_id)] = Assignment{}; +} diff --git a/source/core/input/gamepad_manager.hpp b/source/core/input/gamepad_manager.hpp index 781b2b4..9ab6c40 100644 --- a/source/core/input/gamepad_manager.hpp +++ b/source/core/input/gamepad_manager.hpp @@ -2,6 +2,7 @@ #include +#include #include #include #include @@ -41,22 +42,70 @@ class GamepadManager { void handleEvent(const SDL_Event& ev); // Primary = primer gamepad al vector. `nullptr` si no n'hi ha cap. - // Ús típic: Player 1 i navegació del ServiceMenu. + // Ús típic: navegació del ServiceMenu. Per al gameplay els jugadors + // usen `getForPlayer(id)`. [[nodiscard]] auto getPrimary() const -> SDL_Gamepad*; [[nodiscard]] auto getCount() const -> std::size_t; - // Retorna el mand del jugador `idx`-èsim (0-indexed) o `nullptr` si - // encara no hi ha prou mandos connectats. Reservat per a multi-jugador. + // Retorna el mand del jugador `idx`-èsim (0-indexed) en l'ordre de + // connexió. Per al gameplay, `getForPlayer` fa servir l'assignació + // persistida, no l'ordre de connexió. [[nodiscard]] auto getByPlayerIndex(std::size_t idx) const -> SDL_Gamepad*; + // Informació exposada al menú de servei (llista de mandos + // disponibles). `path` pot estar buit si SDL no l'exposa (p. ex. a + // alguns backends web). + struct Info { + SDL_Gamepad* pad{nullptr}; + std::string name; + std::string path; + }; + [[nodiscard]] auto getAll() const -> std::vector; + + // Retorna el mand assignat al jugador `player_id` (0 = P1, 1 = P2) + // o `nullptr` si no n'hi ha cap. L'assignació la fixa + // `assignToPlayer` (cridat per Options en carregar `config.yaml` o + // des del ServiceMenu quan l'usuari tria un mando). + [[nodiscard]] auto getForPlayer(int player_id) const -> SDL_Gamepad*; + + // Assigna un mando per nom + ruta física. Si ja hi ha un mando + // connectat que coincideix per ruta (o per nom com a fallback), el + // vincula immediatament; si no, l'assignació queda pendent i + // s'aplicarà si el mando es connecta més tard (hot-plug). + // Política last-wins: si l'altre jugador tenia el mateix mando + // vinculat, se'l desvincula silenciosament. + void assignToPlayer(int player_id, const std::string& name, const std::string& path); + + // Esborra l'assignació (per si l'usuari trie "Cap" al menú). + void unassignFromPlayer(int player_id); + private: struct Entry { SDL_Gamepad* pad{nullptr}; SDL_JoystickID instance_id{0}; std::string pretty_name; + std::string path; }; + // Assignació persistent per jugador. `name` i `path` venen d'Options + // (o del menú de servei) i poden referenciar un mando encara no + // connectat; en aquest cas `pad` és `nullptr` i el hot-plug el + // vincularà quan aparegui. + struct Assignment { + std::string name; + std::string path; + SDL_Gamepad* pad{nullptr}; + }; + static constexpr int MAX_PLAYERS = 2; + std::array assignments_{}; + + // Busca a `entries_` el millor match per a una assignació (primer + // per path, després per nom). Retorna l'iterador o `entries_.end()`. + // També accepta assignar `path` nou si ha coincidit per nom + // (cas mando reenchufat a un altre port USB). + void rebindAssignment(Assignment& a); + GamepadManager() = default; ~GamepadManager() = default; diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index db7ce2c..6faa501 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -25,11 +25,14 @@ namespace { // repòs i no contribueix a la velocitat. constexpr Sint16 STICK_DEADZONE = 12000; - // Acumula la contribució del primer gamepad (cruceta + stick esquerre) - // sobre els acumuladors de velocitat. Extracció d'`update()` per a - // mantenir la complexitat cognitiva sota llindar (threshold 25). - void accumulateGamepadMovement(float& vx, float& vy) { - SDL_Gamepad* pad = GamepadManager::get().getPrimary(); + // Acumula la contribució del mando assignat al jugador (cruceta + + // stick esquerre) sobre els acumuladors de velocitat. Extracció + // d'`update()` per a mantenir la complexitat cognitiva sota llindar + // (threshold 25). L'assignació mando↔jugador viu al `GamepadManager` + // i la persisteix Options; si aquest jugador no en té cap assignat, + // la funció és un no-op (sols es mou amb el teclat). + void accumulateGamepadMovement(int player_id, float& vx, float& vy) { + SDL_Gamepad* pad = GamepadManager::get().getForPlayer(player_id); if (pad == nullptr) { return; } if (InputState::getGamepadButton(pad, SDL_GAMEPAD_BUTTON_DPAD_LEFT)) { vx -= SPEED_PX_PER_S; } if (InputState::getGamepadButton(pad, SDL_GAMEPAD_BUTTON_DPAD_RIGHT)) { vx += SPEED_PX_PER_S; } @@ -256,7 +259,7 @@ void Player::update(float delta_time) { // Direccions oposades (teclat left + d-pad right) es cancel·len de // forma natural; premuda simultània en la mateixa direcció no duplica // la velocitat perquè cada branca només resta/suma una vegada. - accumulateGamepadMovement(vx, vy); + accumulateGamepadMovement(id_, vx, vy); setVelX(vx); setVelY(vy); diff --git a/source/game/options.cpp b/source/game/options.cpp index b12773e..9749a41 100644 --- a/source/game/options.cpp +++ b/source/game/options.cpp @@ -9,6 +9,7 @@ #include #include +#include "core/input/gamepad_manager.hpp" #include "external/fkyaml_node.hpp" #include "utils/defines.hpp" @@ -26,6 +27,7 @@ namespace { Options::Window g_window{}; Options::Video g_video{}; Options::Audio g_audio{}; + Options::Controls g_controls{}; std::vector g_postfx_presets{}; std::vector g_crtpi_presets{}; @@ -160,6 +162,29 @@ namespace { readField(node, "volume", g_audio.sound.volume); } + void parseGamepadAssignment(const fkyaml::node& parent, const char* key, Options::GamepadAssignment& dst) { + if (!parent.contains(key)) { return; } + const auto& node = parent[key]; + readField(node, "name", dst.name); + readField(node, "path", dst.path); + } + + void parseControls(const fkyaml::node& yaml) { + if (!yaml.contains("controls")) { return; } + const auto& node = yaml["controls"]; + parseGamepadAssignment(node, "gamepad_p1", g_controls.gamepad_p1); + parseGamepadAssignment(node, "gamepad_p2", g_controls.gamepad_p2); + } + + // Aplica les assignacions persistides al GamepadManager. Cridat + // després de `loadFromFile` perquè l'ordre és: GamepadManager::init + // (obre tots els mandos físics) → Options::loadFromFile (llig YAML) + // → aquesta funció (casa mandos↔jugadors). + void applyControlsToGamepadManager() { + GamepadManager::get().assignToPlayer(0, g_controls.gamepad_p1.name, g_controls.gamepad_p1.path); + GamepadManager::get().assignToPlayer(1, g_controls.gamepad_p2.name, g_controls.gamepad_p2.path); + } + void parseAudio(const fkyaml::node& yaml) { if (!yaml.contains("audio")) { return; } const auto& node = yaml["audio"]; @@ -442,6 +467,7 @@ namespace Options { auto window() -> const Window& { return g_window; } auto video() -> const Video& { return g_video; } auto audio() -> const Audio& { return g_audio; } + auto controls() -> const Controls& { return g_controls; } auto locale() -> const std::string& { return g_locale; } auto configVersion() -> const std::string& { return g_config_version; } auto postfxPresets() -> const std::vector& { return g_postfx_presets; } @@ -513,6 +539,15 @@ namespace Options { saveToFile(); } + void setGamepadAssignment(int player_id, const std::string& name, const std::string& path) { + if (player_id < 0 || player_id > 1) { return; } + auto& slot = (player_id == 0) ? g_controls.gamepad_p1 : g_controls.gamepad_p2; + slot.name = name; + slot.path = path; + GamepadManager::get().assignToPlayer(player_id, name, path); + saveToFile(); + } + void setConfigFile(const std::string& path) { g_config_file_path = path; } @@ -548,6 +583,8 @@ namespace Options { parseWindow(yaml); parseVideo(yaml); parseAudio(yaml); + parseControls(yaml); + applyControlsToGamepadManager(); return true; } catch (const std::exception& e) { std::cout << "Options: error parsejant config.yaml: " << e.what() << '\n'; @@ -594,7 +631,15 @@ namespace Options { out << " volume: " << g_audio.music.volume << "\n"; out << " sound:\n"; out << " enabled: " << (g_audio.sound.enabled ? "true" : "false") << "\n"; - out << " volume: " << g_audio.sound.volume << "\n"; + out << " volume: " << g_audio.sound.volume << "\n\n"; + + out << "controls:\n"; + out << " gamepad_p1:\n"; + out << " name: \"" << g_controls.gamepad_p1.name << "\"\n"; + out << " path: \"" << g_controls.gamepad_p1.path << "\"\n"; + out << " gamepad_p2:\n"; + out << " name: \"" << g_controls.gamepad_p2.name << "\"\n"; + out << " path: \"" << g_controls.gamepad_p2.path << "\"\n"; return writeFile(g_config_file_path, out.str()); } diff --git a/source/game/options.hpp b/source/game/options.hpp index cf698b0..16657fb 100644 --- a/source/game/options.hpp +++ b/source/game/options.hpp @@ -104,10 +104,26 @@ namespace Options { Sound sound{}; }; + // Assignació d'un mando a un jugador: nom visible (el que retorna + // `GamepadManager::Info::name`) + ruta física (de `SDL_GetGamepadPath`). + // Tots dos buits = sense mando. La ruta serveix per re-vincular + // automàticament el mateix mando al mateix port; el nom és el + // fallback quan l'usuari el canvia de port USB. + struct GamepadAssignment { + std::string name; + std::string path; + }; + + struct Controls { + GamepadAssignment gamepad_p1{}; + GamepadAssignment gamepad_p2{}; + }; + // --- Getters de l'estat (read-only) --- [[nodiscard]] auto window() -> const Window&; [[nodiscard]] auto video() -> const Video&; [[nodiscard]] auto audio() -> const Audio&; + [[nodiscard]] auto controls() -> const Controls&; [[nodiscard]] auto locale() -> const std::string&; [[nodiscard]] auto configVersion() -> const std::string&; [[nodiscard]] auto postfxPresets() -> const std::vector&; @@ -132,6 +148,10 @@ namespace Options { void setAudioMusicVolume(float value); void setAudioSoundEnabled(bool value); void setAudioSoundVolume(float value); + // Fixa l'assignació mando→jugador. Persisteix a `config.yaml` i + // propaga a `GamepadManager::assignToPlayer`. `name` i `path` buits + // = desassignació. `player_id`: 0 = P1, 1 = P2. + void setGamepadAssignment(int player_id, const std::string& name, const std::string& path); // --- API pública: càrrega/desament de fitxers --- void setConfigFile(const std::string& path); From e5b8d7731b156b97df1d911e223c34e23d1ad986 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Fri, 24 Apr 2026 15:17:14 +0200 Subject: [PATCH 03/10] =?UTF-8?q?feat(ui):=20tria=20del=20mando=20per=20ju?= =?UTF-8?q?gador=20al=20men=C3=BA=20de=20servei?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/locale/ca.yaml | 5 +++ source/game/ui/service_menu.cpp | 75 ++++++++++++++++++++++++++++++--- 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/data/locale/ca.yaml b/data/locale/ca.yaml index 968c319..f15bb53 100644 --- a/data/locale/ca.yaml +++ b/data/locale/ca.yaml @@ -47,6 +47,7 @@ menu: system: "Sistema" labels: player_1: "Jugador 1" + player_2: "Jugador 2" gamepad_unavailable: "No disponible" items: video_submenu: "Vídeo" @@ -59,6 +60,8 @@ menu: move_down: "Avall" move_left: "Esquerra" move_right: "Dreta" + gamepad_p1: "Comandament P1" + gamepad_p2: "Comandament P2" zoom: "Zoom" screen: "Pantalla completa" vsync: "VSync" @@ -88,3 +91,5 @@ menu: render_info_off: "Apagat" render_info_top: "Dalt" render_info_bottom: "Baix" + gamepad_none: "Cap" + gamepad_disconnected: "Desconnectat" diff --git a/source/game/ui/service_menu.cpp b/source/game/ui/service_menu.cpp index 156c6b2..9495de6 100644 --- a/source/game/ui/service_menu.cpp +++ b/source/game/ui/service_menu.cpp @@ -7,6 +7,7 @@ #include #include "core/audio/audio.hpp" +#include "core/input/gamepad_manager.hpp" #include "core/input/input_state.hpp" #include "core/input/key_config.hpp" #include "core/locale/locale.hpp" @@ -752,14 +753,78 @@ auto ServiceMenu::buildKeyboardControls() -> Page { return page; } +namespace { + + // Índex del cycle per a un jugador donat. 0 = "Cap" (desassignat o + // sense coincidència amb cap mando connectat); 1..N = mando `idx-1` + // de la llista actual de `GamepadManager::getAll()`. La coincidència + // es fa primer per ruta (estable entre sessions) i si no, per nom. + auto currentPadIndex(int player_id) -> std::size_t { + const auto PADS = GamepadManager::get().getAll(); + const auto& slot = (player_id == 0) ? Options::controls().gamepad_p1 + : Options::controls().gamepad_p2; + if (slot.name.empty() && slot.path.empty()) { return 0; } + for (std::size_t i = 0; i < PADS.size(); ++i) { + if (!slot.path.empty() && PADS[i].path == slot.path) { return i + 1; } + } + for (std::size_t i = 0; i < PADS.size(); ++i) { + if (!slot.name.empty() && PADS[i].name == slot.name) { return i + 1; } + } + return 0; + } + + // Text visible per a cada índex del cycle. 0 → "Cap"; altrament + // el nom del mando. Si l'índex queda fora de rang (p.ex. un mando + // desenchufat just abans que es repinte el label) mostrem el nom + // persistit amb un sufix "(desconnectat)" per no deixar l'usuari + // sense feedback. + auto padLabel(int player_id, std::size_t idx) -> std::string { + if (idx == 0) { return Locale::get().text("menu.values.gamepad_none"); } + const auto PADS = GamepadManager::get().getAll(); + const std::size_t POS = idx - 1; + if (POS < PADS.size()) { return PADS[POS].name; } + const auto& slot = (player_id == 0) ? Options::controls().gamepad_p1 + : Options::controls().gamepad_p2; + return slot.name + " (" + Locale::get().text("menu.values.gamepad_disconnected") + ')'; + } + + // Aplica la selecció del cycle: 0 desassigna, altres índexs vinculen + // el mando corresponent. Options persisteix i crida el GamepadManager. + void applyPadSelection(int player_id, std::size_t idx) { + if (idx == 0) { + Options::setGamepadAssignment(player_id, "", ""); + return; + } + const auto PADS = GamepadManager::get().getAll(); + const std::size_t POS = idx - 1; + if (POS >= PADS.size()) { return; } + Options::setGamepadAssignment(player_id, PADS[POS].name, PADS[POS].path); + } + +} // namespace + auto ServiceMenu::buildGamepadControls() -> Page { Page page; page.title_key = "menu.titles.gamepad"; - page.center_items = true; - // Placeholder: un sol LABEL que indica que la pàgina encara no té - // contingut real. Quan s'afegeixi el suport de gamepad, aquesta - // pàgina es substituirà pel llistat real de botons mapejables. - page.items.push_back(makeLabel(Locale::get().text("menu.labels.gamepad_unavailable"))); + + // Un cycle per jugador. L'opció 0 és sempre "Cap"; la resta es + // generen dinàmicament a partir de `GamepadManager::getAll()` cada + // frame, així que un mando acabat de connectar ja apareix sense + // haver de tornar a obrir el submenú. + page.items.push_back(makeCycle( + "menu.items.gamepad_p1", + []() { return currentPadIndex(0); }, + []() { return GamepadManager::get().getAll().size() + 1; }, + [](std::size_t idx) { applyPadSelection(0, idx); }, + [](std::size_t idx) { return padLabel(0, idx); })); + + page.items.push_back(makeCycle( + "menu.items.gamepad_p2", + []() { return currentPadIndex(1); }, + []() { return GamepadManager::get().getAll().size() + 1; }, + [](std::size_t idx) { applyPadSelection(1, idx); }, + [](std::size_t idx) { return padLabel(1, idx); })); + return page; } From 996ff0053e07c455f0b19b58df97a90824c07056 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Fri, 24 Apr 2026 18:06:24 +0200 Subject: [PATCH 04/10] =?UTF-8?q?fix(input):=20dedup=20de=20mandos,=20auto?= =?UTF-8?q?-assign=20per=20ordre=20i=20cycles=20sense=20col=C2=B7lisi?= =?UTF-8?q?=C3=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- source/core/input/gamepad_manager.cpp | 36 ++++++++++++++++++++ source/core/input/gamepad_manager.hpp | 13 ++++++++ source/game/options.cpp | 35 +++++++++++++++++--- source/game/ui/service_menu.cpp | 47 ++++++++++++++++++--------- 4 files changed, 111 insertions(+), 20 deletions(-) diff --git a/source/core/input/gamepad_manager.cpp b/source/core/input/gamepad_manager.cpp index f9d6d2e..1b84f06 100644 --- a/source/core/input/gamepad_manager.cpp +++ b/source/core/input/gamepad_manager.cpp @@ -79,6 +79,17 @@ auto GamepadManager::uniqueName(const std::string& base) const -> std::string { } void GamepadManager::addGamepad(SDL_JoystickID jid, bool notify) { + // Dedup per `instance_id`: SDL encua un SDL_EVENT_GAMEPAD_ADDED per + // cada mando ja connectat quan s'inicialitza el subsistema, a més + // dels que ja hem obert a `init()`. Sense aquesta guarda, el mateix + // mando s'afegiria dues vegades (silenciós + notificat amb sufix + // "#2") i qualsevol referència via `getPrimary`/`entries_` seria + // ambigua. + const bool ALREADY_KNOWN = std::ranges::any_of( + entries_, + [jid](const Entry& e) { return e.instance_id == jid; }); + if (ALREADY_KNOWN) { return; } + installWebStandardMapping(jid); if (!SDL_IsGamepad(jid)) { return; } SDL_Gamepad* pad = SDL_OpenGamepad(jid); @@ -257,3 +268,28 @@ void GamepadManager::unassignFromPlayer(int player_id) { if (player_id < 0 || player_id >= MAX_PLAYERS) { return; } assignments_[static_cast(player_id)] = Assignment{}; } + +auto GamepadManager::autoAssignFreeSlots() -> std::vector { + std::vector assigned; + for (int pid = 0; pid < MAX_PLAYERS; ++pid) { + auto& slot = assignments_[static_cast(pid)]; + if (slot.pad != nullptr) { continue; } + // Primer mando que cap altre slot no haja reclamat. Així respectem + // qualsevol assignació persistida que ja haja vinculat un pad + // concret i no el reassignem a un jugador diferent. + for (const auto& e : entries_) { + const bool TAKEN = std::ranges::any_of( + assignments_, + [&](const Assignment& other) { return other.pad == e.pad; }); + if (TAKEN) { continue; } + slot.pad = e.pad; + slot.name = e.pretty_name; + slot.path = e.path; + assigned.push_back(AutoAssigned{.player_id = pid, .name = e.pretty_name, .path = e.path}); + std::cout << "GamepadManager: auto-assigned " << e.pretty_name + << " → P" << (pid + 1) << '\n'; + break; + } + } + return assigned; +} diff --git a/source/core/input/gamepad_manager.hpp b/source/core/input/gamepad_manager.hpp index 9ab6c40..a040560 100644 --- a/source/core/input/gamepad_manager.hpp +++ b/source/core/input/gamepad_manager.hpp @@ -80,6 +80,19 @@ class GamepadManager { // Esborra l'assignació (per si l'usuari trie "Cap" al menú). void unassignFromPlayer(int player_id); + // Per a cada slot buit, hi vincula el primer mando connectat que no + // estigui reclamat per un altre slot. Equivalent al patró de + // `coffee_crisis_arcade_edition`: les assignacions persistides tenen + // prioritat; els slots que queden buits després s'emplenen per + // ordre. Retorna la llista de parells (player_id, name, path) que + // s'han vinculat, per a que Options pugui persistir-los. + struct AutoAssigned { + int player_id; + std::string name; + std::string path; + }; + auto autoAssignFreeSlots() -> std::vector; + private: struct Entry { SDL_Gamepad* pad{nullptr}; diff --git a/source/game/options.cpp b/source/game/options.cpp index 9749a41..a775bf9 100644 --- a/source/game/options.cpp +++ b/source/game/options.cpp @@ -179,10 +179,24 @@ namespace { // Aplica les assignacions persistides al GamepadManager. Cridat // després de `loadFromFile` perquè l'ordre és: GamepadManager::init // (obre tots els mandos físics) → Options::loadFromFile (llig YAML) - // → aquesta funció (casa mandos↔jugadors). + // → aquesta funció (casa mandos↔jugadors) → auto-assign per ordre + // els slots que hagen quedat buits (equivalent al patró de + // `coffee_crisis_arcade_edition`). void applyControlsToGamepadManager() { - GamepadManager::get().assignToPlayer(0, g_controls.gamepad_p1.name, g_controls.gamepad_p1.path); - GamepadManager::get().assignToPlayer(1, g_controls.gamepad_p2.name, g_controls.gamepad_p2.path); + auto& gm = GamepadManager::get(); + gm.assignToPlayer(0, g_controls.gamepad_p1.name, g_controls.gamepad_p1.path); + gm.assignToPlayer(1, g_controls.gamepad_p2.name, g_controls.gamepad_p2.path); + const auto AUTO = gm.autoAssignFreeSlots(); + if (AUTO.empty()) { return; } + // Sincronitzem Options amb el que el manager ha vinculat per + // ordre, per persistir-ho al config.yaml. No cridem els setters + // individuals perquè cada un dispararia un saveToFile; preferim + // fer un sol save al final. + for (const auto& a : AUTO) { + auto& slot = (a.player_id == 0) ? g_controls.gamepad_p1 : g_controls.gamepad_p2; + slot.name = a.name; + slot.path = a.path; + } } void parseAudio(const fkyaml::node& yaml) { @@ -557,7 +571,11 @@ namespace Options { const std::string CONTENT = readFileOrEmpty(g_config_file_path); if (CONTENT.empty()) { - // Primera execució: creem el fitxer amb els valors per defecte. + // Primera execució: fem l'auto-assign inicial dels mandos + // (per a que P1 obtingui el primer mando connectat sense que + // l'usuari haja de passar pel menú) i després escrivim el + // fitxer amb els valors resultants. + applyControlsToGamepadManager(); return saveToFile(); } @@ -584,7 +602,16 @@ namespace Options { parseVideo(yaml); parseAudio(yaml); parseControls(yaml); + // Captura les assignacions inicials per detectar si + // l'auto-assign (dins d'`applyControls...`) les ha tocat i + // cal reescriure el fitxer amb els valors nous. + const auto BEFORE_P1 = g_controls.gamepad_p1; + const auto BEFORE_P2 = g_controls.gamepad_p2; applyControlsToGamepadManager(); + const bool CHANGED = + (BEFORE_P1.name != g_controls.gamepad_p1.name || BEFORE_P1.path != g_controls.gamepad_p1.path || + BEFORE_P2.name != g_controls.gamepad_p2.name || BEFORE_P2.path != g_controls.gamepad_p2.path); + if (CHANGED) { saveToFile(); } return true; } catch (const std::exception& e) { std::cout << "Options: error parsejant config.yaml: " << e.what() << '\n'; diff --git a/source/game/ui/service_menu.cpp b/source/game/ui/service_menu.cpp index 9495de6..aa5931f 100644 --- a/source/game/ui/service_menu.cpp +++ b/source/game/ui/service_menu.cpp @@ -755,12 +755,31 @@ auto ServiceMenu::buildKeyboardControls() -> Page { namespace { - // Índex del cycle per a un jugador donat. 0 = "Cap" (desassignat o - // sense coincidència amb cap mando connectat); 1..N = mando `idx-1` - // de la llista actual de `GamepadManager::getAll()`. La coincidència - // es fa primer per ruta (estable entre sessions) i si no, per nom. + // Mandos visibles al cycle d'un jugador: tots els connectats + // EXCEPTE els reclamats per l'altre jugador. Així l'usuari mai pot + // assignar el mateix mando a dos jugadors des de la UI — per + // reassignar-lo ha d'alliberar-lo primer posant-lo a "Cap" a l'altre + // slot. L'ordre de la llista manté el de `getAll()`. + auto visiblePads(int player_id) -> std::vector { + const auto ALL = GamepadManager::get().getAll(); + const auto& other = (player_id == 0) ? Options::controls().gamepad_p2 + : Options::controls().gamepad_p1; + std::vector out; + out.reserve(ALL.size()); + for (const auto& p : ALL) { + const bool OWNED_BY_OTHER = + (!other.path.empty() && p.path == other.path) || + (other.path.empty() && !other.name.empty() && p.name == other.name); + if (!OWNED_BY_OTHER) { out.push_back(p); } + } + return out; + } + + // Índex del cycle dins de la llista `visiblePads(player_id)`. + // 0 = "Cap" (desassignat o sense coincidència amb cap mando visible); + // 1..N = mando `idx-1`. Primer match per ruta, fallback per nom. auto currentPadIndex(int player_id) -> std::size_t { - const auto PADS = GamepadManager::get().getAll(); + const auto PADS = visiblePads(player_id); const auto& slot = (player_id == 0) ? Options::controls().gamepad_p1 : Options::controls().gamepad_p2; if (slot.name.empty() && slot.path.empty()) { return 0; } @@ -773,29 +792,25 @@ namespace { return 0; } - // Text visible per a cada índex del cycle. 0 → "Cap"; altrament - // el nom del mando. Si l'índex queda fora de rang (p.ex. un mando - // desenchufat just abans que es repinte el label) mostrem el nom - // persistit amb un sufix "(desconnectat)" per no deixar l'usuari - // sense feedback. auto padLabel(int player_id, std::size_t idx) -> std::string { if (idx == 0) { return Locale::get().text("menu.values.gamepad_none"); } - const auto PADS = GamepadManager::get().getAll(); + const auto PADS = visiblePads(player_id); const std::size_t POS = idx - 1; if (POS < PADS.size()) { return PADS[POS].name; } + // Fora de rang: l'assignació guardada no és visible ara (p. ex. + // el mando s'acaba de desconnectar). Mostrem el nom persistit + // amb un sufix per no deixar l'usuari sense feedback. const auto& slot = (player_id == 0) ? Options::controls().gamepad_p1 : Options::controls().gamepad_p2; return slot.name + " (" + Locale::get().text("menu.values.gamepad_disconnected") + ')'; } - // Aplica la selecció del cycle: 0 desassigna, altres índexs vinculen - // el mando corresponent. Options persisteix i crida el GamepadManager. void applyPadSelection(int player_id, std::size_t idx) { if (idx == 0) { Options::setGamepadAssignment(player_id, "", ""); return; } - const auto PADS = GamepadManager::get().getAll(); + const auto PADS = visiblePads(player_id); const std::size_t POS = idx - 1; if (POS >= PADS.size()) { return; } Options::setGamepadAssignment(player_id, PADS[POS].name, PADS[POS].path); @@ -814,14 +829,14 @@ auto ServiceMenu::buildGamepadControls() -> Page { page.items.push_back(makeCycle( "menu.items.gamepad_p1", []() { return currentPadIndex(0); }, - []() { return GamepadManager::get().getAll().size() + 1; }, + []() { return visiblePads(0).size() + 1; }, [](std::size_t idx) { applyPadSelection(0, idx); }, [](std::size_t idx) { return padLabel(0, idx); })); page.items.push_back(makeCycle( "menu.items.gamepad_p2", []() { return currentPadIndex(1); }, - []() { return GamepadManager::get().getAll().size() + 1; }, + []() { return visiblePads(1).size() + 1; }, [](std::size_t idx) { applyPadSelection(1, idx); }, [](std::size_t idx) { return padLabel(1, idx); })); From f3977170b70abd546e5af8c864c6fe00624264b5 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Fri, 24 Apr 2026 18:15:39 +0200 Subject: [PATCH 05/10] =?UTF-8?q?fix(ui):=20trunca=20el=20value=20al=20men?= =?UTF-8?q?=C3=BA=20amb=20"..."=20quan=20se=20sol=C2=B7lapa=20amb=20la=20l?= =?UTF-8?q?abel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- source/game/ui/service_menu.cpp | 55 ++++++++++++++++++++++++++++----- 1 file changed, 48 insertions(+), 7 deletions(-) diff --git a/source/game/ui/service_menu.cpp b/source/game/ui/service_menu.cpp index aa5931f..9ee4d91 100644 --- a/source/game/ui/service_menu.cpp +++ b/source/game/ui/service_menu.cpp @@ -35,6 +35,10 @@ namespace { constexpr int PAD_X = 10; constexpr int GAP_Y = 6; constexpr int ITEM_GAP_Y = 2; + // Mínim buit entre la label (a l'esquerra) i el value (a la dreta). + // Si amb el value sencer no queda aquest gap, truncam amb "..." des + // del final del value. + constexpr int MIN_LABEL_VALUE_GAP = 6; // Les fonts display reserven ~2 px de descendent al fons de la seua // caixa (per a `g`, `p`, `q`...). Quan el títol no porta cap d'aquests // glifs, eixos 2 px queden buits i el gap visual sota el títol sembla @@ -755,6 +759,48 @@ auto ServiceMenu::buildKeyboardControls() -> Page { namespace { + // Trunca `text` amb "..." perquè la seua amplada renderitzada no + // supere `max_w` píxels amb la font i el kerning donats. Port del + // helper de coffee_crisis_arcade_edition: estimació inicial a partir + // de l'amplada mitjana per caràcter i ajust iteratiu mesurant amb + // `Text::length`, per a fonts d'amplada variable on l'estimació + // només és aproximada. + auto truncateToFit(const Text& font, const std::string& text, int max_w, int kerning) -> std::string { + if (font.length(text, kerning) <= max_w) { return text; } + static constexpr const char* ELLIPSIS = "..."; + const int ELLIPSIS_W = font.length(ELLIPSIS, kerning); + if (max_w <= ELLIPSIS_W || text.empty()) { return ELLIPSIS; } + const auto TOTAL_W = static_cast(font.length(text, kerning)); + const auto AVG_CHAR_W = TOTAL_W / static_cast(text.size()); + auto keep = static_cast(static_cast(max_w - ELLIPSIS_W) / AVG_CHAR_W); + keep = std::min(keep, text.size()); + std::string out = text.substr(0, keep) + ELLIPSIS; + while (keep > 0 && font.length(out, kerning) > max_w) { + --keep; + out = text.substr(0, keep) + ELLIPSIS; + } + return out; + } + + // Dibuixa el value d'un item a la dreta del box, truncant-lo amb + // "..." si no cap a l'esquerra de la label. Extret de `renderPage` + // per a mantenir la complexitat cognitiva sota el llindar de + // clang-tidy. + void drawItemValue(Text& font, int box_x, int box_w, int cursor_y, int cursor_width, const std::string& label, const std::string& value, int kerning, Uint8 color) { + const int LABEL_W = font.length(label, kerning); + const int AVAILABLE_W = box_w - (2 * PAD_X) - cursor_width - LABEL_W - MIN_LABEL_VALUE_GAP; + if (AVAILABLE_W <= 0) { return; } + const std::string DISPLAY = truncateToFit(font, value, AVAILABLE_W, kerning); + if (DISPLAY.empty()) { return; } + const int VALUE_W = font.length(DISPLAY, kerning); + font.writeDX(Text::COLOR_FLAG, + box_x + box_w - PAD_X - VALUE_W, + cursor_y, + DISPLAY, + kerning, + color); + } + // Mandos visibles al cycle d'un jugador: tots els connectats // EXCEPTE els reclamats per l'altre jugador. Així l'usuari mai pot // assignar el mateix mando a dos jugadors des de la UI — per @@ -1025,13 +1071,8 @@ void ServiceMenu::renderPage(const Page& page, int box_x, int box_y, int box_w) // només tenen ACTION / LABEL); els Toggle/Cycle viuen en pàgines // d'opcions amb layout esquerra-dreta. if (!page.center_items && !VALUE.empty()) { - const int VALUE_W = items_font_->length(VALUE, ITEM_KERNING); - items_font_->writeDX(Text::COLOR_FLAG, - box_x + box_w - PAD_X - VALUE_W, - cursor_y, - VALUE, - ITEM_KERNING, - SELECTED ? UiConfig::get().menuCursor() : UiConfig::get().menuValue()); + const Uint8 VALUE_COLOR = SELECTED ? UiConfig::get().menuCursor() : UiConfig::get().menuValue(); + drawItemValue(*items_font_, box_x, box_w, cursor_y, CURSOR_WIDTH, LABEL_TEXT, VALUE, ITEM_KERNING, VALUE_COLOR); } cursor_y += ITEM_H; From 820b9f2d219ec64a1914180b811a29afc53f83ff Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Fri, 24 Apr 2026 18:18:55 +0200 Subject: [PATCH 06/10] =?UTF-8?q?feat(ui):=20bloc=20del=20jugador=202=20al?= =?UTF-8?q?=20men=C3=BA=20de=20teclat=20(nom=C3=A9s=20lectura)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- source/game/ui/service_menu.cpp | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/source/game/ui/service_menu.cpp b/source/game/ui/service_menu.cpp index 9ee4d91..7004cc8 100644 --- a/source/game/ui/service_menu.cpp +++ b/source/game/ui/service_menu.cpp @@ -736,15 +736,14 @@ auto ServiceMenu::buildKeyboardControls() -> Page { Page page; page.title_key = "menu.titles.keyboard"; - // Etiqueta divisòria per al jugador 1. Quan s'afegeixi el jugador 2, - // només s'ha d'apilar un segon bloc "Jugador 2" amb les seves tecles - // per sota, sense tocar cap altra cosa. + // Etiqueta divisòria per al jugador 1. page.items.push_back(makeLabel(Locale::get().text("menu.labels.player_1"))); - // Vinculacions actuals: les llegim directament de `Defaults::KeysGame`. - // Encara no hi ha setter, per tant els items surten com a informatius - // (no seleccionables). La lògica de captura i persistència arribarà en - // una segona iteració amb `Options::keysGameP1()`. + // Vinculacions actuals: les llegim directament de `Defaults::KeysGame` + // (P1) i `Defaults::KeysGameP2` (P2). Encara no hi ha setter, per + // tant els items surten com a informatius (no seleccionables). La + // lògica de captura i persistència arribarà en una segona iteració + // amb `Options::keysGameP1()` / `keysGameP2()`. page.items.push_back(makeKeyBind("menu.items.move_up", []() { return Defaults::KeysGame::UP; })); page.items.push_back(makeKeyBind("menu.items.move_down", @@ -754,6 +753,17 @@ auto ServiceMenu::buildKeyboardControls() -> Page { page.items.push_back(makeKeyBind("menu.items.move_right", []() { return Defaults::KeysGame::RIGHT; })); + // Bloc del jugador 2: mateixa forma, llegint de KeysGameP2. + page.items.push_back(makeLabel(Locale::get().text("menu.labels.player_2"))); + page.items.push_back(makeKeyBind("menu.items.move_up", + []() { return Defaults::KeysGameP2::UP; })); + page.items.push_back(makeKeyBind("menu.items.move_down", + []() { return Defaults::KeysGameP2::DOWN; })); + page.items.push_back(makeKeyBind("menu.items.move_left", + []() { return Defaults::KeysGameP2::LEFT; })); + page.items.push_back(makeKeyBind("menu.items.move_right", + []() { return Defaults::KeysGameP2::RIGHT; })); + return page; } From 1cc2e28b1b71f4bf418ca8a78f9aaa153474f9ad Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Fri, 24 Apr 2026 18:49:52 +0200 Subject: [PATCH 07/10] refactor(player): divideix update en tick+applyMovement i exposa velocitat intencionada --- source/game/entities/player.cpp | 61 ++++++++++++++++++++------------- source/game/entities/player.hpp | 50 +++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 23 deletions(-) diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index 6faa501..a1ba91a 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -241,6 +241,21 @@ Player::Player(const std::string& anim_key, int id, float x, float y, const Cont } void Player::update(float delta_time) { + tick(delta_time); + applyMovement(delta_time); +} + +auto Player::collidesWithWorld(const SDL_FRect& box) const -> bool { + if (map_.collides(box)) { return true; } + if (all_players_ == nullptr) { return false; } + return std::ranges::any_of(*all_players_, [this, &box](const Player* p) { + if (p == nullptr || p == this) { return false; } + const auto OTHER = p->getBaseBox(); + return SDL_HasRectIntersectionFloat(&box, &OTHER); + }); +} + +void Player::tick(float delta_time) { // Via `InputState` en lloc de `SDL_GetKeyboardState` directe: quan el // service menu (o qualsevol UI modal) està obert, el wrapper retorna // un buffer tot-a-zero perquè el jugador no es mogui mentre l'usuari @@ -253,31 +268,32 @@ void Player::update(float delta_time) { if (keys[controls_.up]) { vy -= SPEED_PX_PER_S; } if (keys[controls_.down]) { vy += SPEED_PX_PER_S; } - // Cruceta i stick esquerre del primer gamepad (si n'hi ha). També + // Cruceta i stick esquerre del mando assignat (si n'hi ha). També // respecten el bloqueig d'`InputState`, així que amb el menú obert // els botons tornen false/0 i no contribueixen a la velocitat. - // Direccions oposades (teclat left + d-pad right) es cancel·len de - // forma natural; premuda simultània en la mateixa direcció no duplica - // la velocitat perquè cada branca només resta/suma una vegada. accumulateGamepadMovement(id_, vx, vy); + intended_vx_ = vx; + intended_vy_ = vy; setVelX(vx); setVelY(vy); updateFacingAndTurn(delta_time, vx, vy); +} + +void Player::applyMovement(float delta_time) { + // El resolver del GameScene pot haver escalat `intended_vx_/vy_` + // (cas push: velocitat * push_factor) o haver-les anul·lat (cas + // head-on). Consumim-les aquí com a velocitat definitiva del frame. + const float VX = intended_vx_; + const float VY = intended_vy_; // Snap al passadís: si només hi ha moviment horitzontal (o vertical) i // la caixa està prou prop d'una lane perpendicular (≤ mitja caixa), la - // Y (o X) es corregeix gradualment per enfilar el passadís. Amb - // moviment diagonal no es fa snap — l'usuari pilota manualment. - const bool ONLY_H = (vx != 0.0F) && (vy == 0.0F); - const bool ONLY_V = (vy != 0.0F) && (vx == 0.0F); + // Y (o X) es corregeix gradualment per enfilar el passadís. + const bool ONLY_H = (VX != 0.0F) && (VY == 0.0F); + const bool ONLY_V = (VY != 0.0F) && (VX == 0.0F); - // Pos d'inici de frame (abans de snap i integració): si en acabar el - // frame coincideix amb la posició final, el jugador no s'ha mogut - // realment (ni per input, ni per snap, ni ha superat la col·lisió) i cal - // forçar l'animació `stand` corresponent al facing — així s'atura el - // caminat quan xoca contra un mur o un sarcòfag. const float FRAME_START_X = getPosX(); const float FRAME_START_Y = getPosY(); @@ -288,20 +304,19 @@ void Player::update(float delta_time) { } // Animació del frame + moviment col·lisió-aware. `tryMove` aplica els - // deltes per eix i reverteix l'eix que col·lisiona, així el jugador - // llisca contra sarcòfags i murs en compte de quedar-se bloquejat en - // sec. La caixa que s'envia al callback és `collisionBox()` (override - // a Player retornant `getBaseBox()`), no el sprite sencer. + // deltes per eix i reverteix l'eix que col·lisiona; així el jugador + // llisca contra sarcòfags, murs i altres jugadors en compte de quedar + // bloquejat en sec. La callback tracta els altres jugadors com a + // sòlids per evitar overlaps (el resolver del GameScene deixa les + // velocitats "segures", i aquesta comprovació és la xarxa de + // seguretat davant de casos d'ordre d'aplicació). animate(delta_time); - tryMove(vx * delta_time, vy * delta_time, [this](const SDL_FRect& box) { return map_.collides(box); }); + tryMove(VX * delta_time, VY * delta_time, [this](const SDL_FRect& box) { + return collidesWithWorld(box); + }); clampToPlayfield(); - // Animació basada en moviment real: si la col·lisió (o l'absència - // d'input) ha deixat el jugador al mateix lloc que al principi del - // frame, queda en `stand` del facing actual. Durant un gir de 180° - // forcem també `stand` encara que el jugador s'estiga movent — la - // pose intermèdia és sempre estàtica per disseny. const bool MOVED = (getPosX() != FRAME_START_X) || (getPosY() != FRAME_START_Y); const bool ANIM_AS_MOVING = MOVED && (turning_remaining_ms_ <= 0.0F); setCurrentAnimation(animationName(ANIM_AS_MOVING)); diff --git a/source/game/entities/player.hpp b/source/game/entities/player.hpp index 7109eb7..00de35b 100644 --- a/source/game/entities/player.hpp +++ b/source/game/entities/player.hpp @@ -5,6 +5,7 @@ #include #include #include +#include #include "core/rendering/sprite/animated_sprite.hpp" #include "game/map/map.hpp" @@ -44,14 +45,48 @@ class Player : public AnimatedSprite { 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 + // escenaris amb un sol jugador. Internament és equivalent a cridar + // `tick(dt)` seguit d'`applyMovement(dt)`. void update(float delta_time) override; + // Fase 1: llig l'input d'aquest frame i emmagatzema la velocitat + // intencionada a `intended_vx_` / `intended_vy_`. No mou el jugador. + // Separada d'`applyMovement` perquè el GameScene puga resoldre + // interaccions entre jugadors (empentes, neutralitzacions head-on) + // abans que ningú consume la posició. + void tick(float delta_time); + + // Fase 2: consumeix `intended_vx_`/`intended_vy_` (possiblement + // modificats pel resolver), crida `tryMove` contra el mapa i els + // altres jugadors, fa snap al passadís, clamp al playfield, avança + // l'animació i publica `on_tile_entered` si ha canviat de tile. + void applyMovement(float delta_time); + // Identificador local (0 = P1, 1 = P2…). S'usa als events del bus // perquè els consumidors (HUD, futures regles de puntuació) sàpiguen // quin jugador ha originat l'event. No té cap vinculació amb la // xarxa — és únic per dins del procés. [[nodiscard]] auto getId() const -> int { return id_; } + // Accés i mutació de la velocitat intencionada d'aquest frame. El + // GameScene els toca entre `tick` i `applyMovement` per implementar + // col·lisions jugador↔jugador (escalar a push_factor, anul·lar en + // head-on). Expressades en píxels/segon, no en píxels/frame. + [[nodiscard]] auto getIntendedVX() const -> float { return intended_vx_; } + [[nodiscard]] auto getIntendedVY() const -> float { return intended_vy_; } + void setIntendedVelocity(float vx, float vy) { + intended_vx_ = vx; + intended_vy_ = vy; + } + + // Llista de tots els jugadors del nivell (incloent-se ell mateix — + // la callback ignora `this`). La caixa del jugador es considera un + // sòlid a la callback de col·lisió: així `tryMove` no permet que un + // jugador travesse l'altre, i els desplaçaments parcials (push + // contra mur) s'emparen automàticament. + void setOtherPlayers(const std::vector* all) { all_players_ = all; } + // Y de la base del jugador (cara inferior de la caixa de colisió). Usada // per al Z-order contra els sarcs: peus per sobre d'un sarc → darrere; // per sota → davant. @@ -87,6 +122,11 @@ class Player : public AnimatedSprite { // (snapToLane, clamp). Els consumidors externs usen `getBaseBox()`. [[nodiscard]] auto baseBox() const -> SDL_FRect { return getBaseBox(); } + // Callback de col·lisió per a `tryMove`: true si la caixa proposada + // xoca amb el mapa o amb un altre jugador. Membre per mantenir la + // complexitat cognitiva d'`applyMovement` sota el llindar. + [[nodiscard]] auto collidesWithWorld(const SDL_FRect& box) const -> bool; + // Clamp de la caixa de col·lisió al playfield (no al sprite sencer: // el cap pot sobresortir pel mur superior sense afectar el joc). void clampToPlayfield(); @@ -112,6 +152,16 @@ class Player : public AnimatedSprite { int id_{-1}; Controls controls_; + + // Velocitat intencionada calculada a `tick` i aplicada a + // `applyMovement`. El GameScene pot sobreescriure-la entremig per + // resoldre interaccions jugador↔jugador (empentes, head-on). + float intended_vx_{0.0F}; + float intended_vy_{0.0F}; + + // Punter a la llista de tots els jugadors del nivell (propietat del + // GameScene). `nullptr` = jugador aïllat, sense col·lisió inter-P. + const std::vector* all_players_{nullptr}; // Còpia plana de la geometria del mapa + punters a `collides`/ // `inside_playfield`. POD trivial; copiada al ctor i no muta durant // la vida del Player. Vol dir que aquest Player només és vàlid per From e22f3240da4f0744780da623ef25388cbc189f31 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Fri, 24 Apr 2026 18:54:19 +0200 Subject: [PATCH 08/10] =?UTF-8?q?feat(player):=20col=C2=B7lisi=C3=B3=20i?= =?UTF-8?q?=20empenta=20entre=20jugadors=20amb=20push=5Ffactor=20al=20YAML?= =?UTF-8?q?=20del=20mapa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/maps/pyramid.yaml | 6 ++ source/game/scenes/game_scene.cpp | 130 +++++++++++++++++++++++++++--- source/game/scenes/game_scene.hpp | 12 +++ source/utils/defaults.hpp | 5 ++ 4 files changed, 143 insertions(+), 10 deletions(-) diff --git a/data/maps/pyramid.yaml b/data/maps/pyramid.yaml index 981b1fd..34ebc0a 100644 --- a/data/maps/pyramid.yaml +++ b/data/maps/pyramid.yaml @@ -21,6 +21,12 @@ origin: [-8, 0] player1_paint_tile: 0 player2_paint_tile: 4 +# Física del contacte entre jugadors. Quan un jugador està quiet i l'altre +# el topa, el segon l'empenta a aquesta fracció de la velocitat normal (i +# ell mateix es ralentitza per mantenir el grup). En head-on, ambdós +# s'anul·len (sense push, sense moviment). +push_factor: 0.30 + # Geometria lògica de la piràmide (separada del render visual del tileset). # La graella de sarcòfags, el pitch entre ells i les amplades dels murs # decideixen els límits del jugador i les seues caixes de col·lisió. Abans diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index 7de426f..363dd49 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -62,6 +62,98 @@ namespace { return yaml[key].get_value(); } + auto readOptionalFloat(const std::string& yaml_path, const std::string& key, float fallback) -> float { + auto bytes = Resource::Helper::loadFile(yaml_path); + if (bytes.empty()) { return fallback; } + const auto* begin = reinterpret_cast(bytes.data()); + const auto* end = begin + bytes.size(); + auto yaml = fkyaml::node::deserialize(begin, end); + if (!yaml.contains(key)) { return fallback; } + return yaml[key].get_value(); + } + + // Comprova si `a` i `b` se solapen amb una tolerància positiva al + // contacte perfecte (cantos que es toquen = no-solapament). SDL3 ja + // ofereix `SDL_HasRectIntersectionFloat`, però aquí volem la mateixa + // semàntica que al Player::collidesWithWorld. + auto rectsOverlap(const SDL_FRect& a, const SDL_FRect& b) -> bool { + return SDL_HasRectIntersectionFloat(&a, &b); + } + + // Resolver jugador↔jugador. Opera sobre les velocitats intencionades + // (escrites per `Player::tick`, abans d'`applyMovement`) per a + // implementar les regles: + // - Sense overlap previst → no toca res. + // - Ambdós amb input que els faria col·lidir → head-on: velocitats + // anul·lades per als dos. + // - Un quiet, l'altre empentant-lo → push: ambdues velocitats + // escalades a `push_factor` i el pushed hereta la direcció del + // pusher. Si el pushed no pot avançar (mur/sarc), `tryMove` ja ho + // gestionarà a `applyMovement` mogent la distància parcial possible. + // Amb dos jugadors només és un parell; si en un futur n'hi ha més, + // la mateixa lògica es pot aplicar pairwise. + void resolvePlayerVsPlayer(const std::vector& ps, float dt, float push_factor) { + if (ps.size() < 2) { return; } + auto* a = ps[0]; + auto* b = ps[1]; + if (a == nullptr || b == nullptr) { return; } + + const SDL_FRect BOX_A = a->getBaseBox(); + const SDL_FRect BOX_B = b->getBaseBox(); + const SDL_FRect NEXT_A{ + .x = BOX_A.x + (a->getIntendedVX() * dt), + .y = BOX_A.y + (a->getIntendedVY() * dt), + .w = BOX_A.w, + .h = BOX_A.h, + }; + const SDL_FRect NEXT_B{ + .x = BOX_B.x + (b->getIntendedVX() * dt), + .y = BOX_B.y + (b->getIntendedVY() * dt), + .w = BOX_B.w, + .h = BOX_B.h, + }; + if (!rectsOverlap(NEXT_A, NEXT_B)) { return; } + + const bool MOVING_A = (a->getIntendedVX() != 0.0F) || (a->getIntendedVY() != 0.0F); + const bool MOVING_B = (b->getIntendedVX() != 0.0F) || (b->getIntendedVY() != 0.0F); + + if (MOVING_A && MOVING_B) { + // Head-on o encreuament amb col·lisió: cap dels dos es mou. + a->setIntendedVelocity(0.0F, 0.0F); + b->setIntendedVelocity(0.0F, 0.0F); + return; + } + + auto* pusher = MOVING_A ? a : b; + auto* pushed = MOVING_A ? b : a; + const float VX = pusher->getIntendedVX() * push_factor; + const float VY = pusher->getIntendedVY() * push_factor; + pusher->setIntendedVelocity(VX, VY); + pushed->setIntendedVelocity(VX, VY); + } + + // Ordre d'`applyMovement`: en push, el pushed ha de moure's abans + // perquè quan el pusher faça `tryMove` veja el pushed a la seua + // posició nova i no li bloqueje. Heurística barata: el jugador amb + // `intended*dt` que entra a l'altre és el pusher; però com a totes + // les configuracions push els dos tenen la mateixa velocitat resultant + // i comparteixen direcció, usem el producte (dot) del vector d'acció + // i el vector cap a l'altre jugador: positiu = pusher. + auto orderedForMovement(const std::vector& ps) -> std::vector { + std::vector out = ps; + if (out.size() < 2 || out[0] == nullptr || out[1] == nullptr) { return out; } + const auto* a = out[0]; + const auto* b = out[1]; + const SDL_FRect BOX_A = a->getBaseBox(); + const SDL_FRect BOX_B = b->getBaseBox(); + const float DX = (BOX_B.x + (BOX_B.w / 2.0F)) - (BOX_A.x + (BOX_A.w / 2.0F)); + const float DY = (BOX_B.y + (BOX_B.h / 2.0F)) - (BOX_A.y + (BOX_A.h / 2.0F)); + // dot(v_a, dir_a→b): positiu = a empenta a b → b es mou primer. + const float DOT_A = (a->getIntendedVX() * DX) + (a->getIntendedVY() * DY); + if (DOT_A > 0.0F) { std::swap(out[0], out[1]); } + return out; + } + } // namespace auto GameScene::defaultConfig() -> Config { @@ -189,6 +281,16 @@ GameScene::GameScene(Config config) // sarcòfags passa per `tile_painted`. for (auto& p : players_) { p->setEventBus(&entity_manager_.getBus()); } + // Física del contacte entre jugadors. El factor viu al YAML del mapa + // com a propietat de la piràmide (0.30 per defecte). La vista plana + // `players_view_` i el `setOtherPlayers` s'han de fer ara perquè a + // cada `applyMovement` la callback de col·lisió consulti els altres + // jugadors com a sòlids. + push_factor_ = readOptionalFloat(config_.map_yaml, "push_factor", Defaults::Player::PUSH_FACTOR); + players_view_.reserve(players_.size()); + std::ranges::transform(players_, std::back_inserter(players_view_), [](const auto& p) { return p.get(); }); + for (auto& p : players_) { p->setOtherPlayers(&players_view_); } + // Marcador (HUD) de la franja superior. Reutilitza la font 8bithud i // la paleta "ui" ja precarregades pel Director — no toca disc des // d'aquí. Subscrit al bus per reaccionar a `player_hit`, @@ -338,7 +440,21 @@ void GameScene::update(int delta_ms) { const int SCALED_DELTA_MS = static_cast(static_cast(delta_ms) * pause_.scale); if (!EDITOR_FROZEN && SCALED_DELTA_MS > 0) { const float DT = static_cast(SCALED_DELTA_MS) / 1000.0F; - for (auto& player : players_) { player->update(DT); } + + // Pipeline de moviment en tres passes per poder intercalar la + // resolució de contactes jugador↔jugador entre input i posició: + // 1) tick: cada jugador calcula la velocitat intencionada + // (teclat + mando + facing). + // 2) resolve: si els dos es trobarien, anul·lem velocitats + // (head-on) o escalem a push_factor (empenta). + // 3) applyMovement: en l'ordre "pushed primer" perquè quan el + // pusher faça `tryMove`, el pushed ja estiga fora de la + // seua caixa. La callback també tracta l'altre jugador com + // a sòlid: xarxa de seguretat davant de qualsevol inexactitud. + for (auto& player : players_) { player->tick(DT); } + resolvePlayerVsPlayer(players_view_, DT, push_factor_); + for (auto* player : orderedForMovement(players_view_)) { player->applyMovement(DT); } + // El tick d'entitats DESPRÉS del tick dels jugadors: si el jugador ha // emès `on_tile_entered` aquest frame, l'EntityManager ja l'ha encaminat // als sarcòfags via la subscripció del bus, i aquest `update` pot @@ -346,15 +462,9 @@ void GameScene::update(int delta_ms) { entity_manager_.update(DT); // Detecció de contacte player↔entitat després que totes les entitats - // hagin mogut aquest frame. La porta (si està oberta) emet - // `level_complete`; la momia/trampa emet `player_hit`. Els sarcs, encara - // que tinguen collides=true, tenen la caixa lògica fora del camí del - // jugador (gràcies a Map::collides al Player::update), així que aquí - // no generen falsos contactes. - std::vector player_ptrs; - player_ptrs.reserve(players_.size()); - std::ranges::transform(players_, std::back_inserter(player_ptrs), [](const auto& p) { return p.get(); }); - entity_manager_.checkPlayerCollisions(player_ptrs); + // hagin mogut aquest frame. Reutilitzem la vista plana que ja + // manté el GameScene per al resolver player↔player. + entity_manager_.checkPlayerCollisions(players_view_); } #ifdef _DEBUG diff --git a/source/game/scenes/game_scene.hpp b/source/game/scenes/game_scene.hpp index 6e4bf0a..a82db53 100644 --- a/source/game/scenes/game_scene.hpp +++ b/source/game/scenes/game_scene.hpp @@ -68,6 +68,18 @@ class GameScene : public Scene { // té els seus propis Controls i entra al vector amb una crida extra. std::vector> players_; + // Vista plana de punters crus — la fa servir Player com a llista + // d'obstacles mòbils a la callback de col·lisió. Es construeix un sol + // cop al ctor (els punters són estables perquè `players_` no es + // redimensiona dins de la vida de l'escena). + std::vector players_view_; + + // Factor multiplicatiu del push inter-jugador. Carregat de la clau + // `push_factor` del YAML del mapa; 0.30 per defecte (veure + // `Defaults::Player::PUSH_FACTOR`). Constant dins de la vida de + // l'escena — una piràmide, una física. + float push_factor_{0.30F}; + // Capa de "pintat" del terra. Escolta `on_tile_entered` al bus, // filtra pintabilitat (sòl transitable) i re-publica `tile_painted` // per als sarcòfags. `unique_ptr` per controlar l'ordre de diff --git a/source/utils/defaults.hpp b/source/utils/defaults.hpp index 77f6a72..dbef97f 100644 --- a/source/utils/defaults.hpp +++ b/source/utils/defaults.hpp @@ -93,6 +93,11 @@ namespace Defaults { namespace Player { constexpr float SPEED_PX_PER_S = 60.0F; // Velocitat base del jugador (sense sprint / slow-zones). + // Factor multiplicatiu aplicat a la velocitat quan un jugador empenta + // l'altre (0.30 = el pusher es ralentitza al 30% i el pushed és + // arrossegat a la mateixa velocitat). Sobrescrivible des del YAML + // del mapa amb la clau `push_factor`. + constexpr float PUSH_FACTOR = 0.30F; } // namespace Player // Fallbacks del tema d'UI: índexs de paleta, timings (ms) i fonts. From 511f8d5b099f3dca3461fcbdf552edd13b89f665 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Fri, 24 Apr 2026 19:00:04 +0200 Subject: [PATCH 09/10] fix(player): el jugador empentat es queda en stand, no walk --- source/game/entities/player.cpp | 10 +++++++++- source/game/entities/player.hpp | 13 +++++++++++++ source/game/scenes/game_scene.cpp | 3 +++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index a1ba91a..dc8b54a 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -256,6 +256,11 @@ auto Player::collidesWithWorld(const SDL_FRect& box) const -> bool { } void Player::tick(float delta_time) { + // Reset per frame: el resolver del GameScene el tornarà a posar a + // true si aquest frame hi ha push actiu. Així quan el push acaba + // (un deixa d'empentar, l'altre comença a caminar), l'animació torna + // a governar-se per la velocitat real sense estat romanent. + being_pushed_ = false; // Via `InputState` en lloc de `SDL_GetKeyboardState` directe: quan el // service menu (o qualsevol UI modal) està obert, el wrapper retorna // un buffer tot-a-zero perquè el jugador no es mogui mentre l'usuari @@ -318,7 +323,10 @@ void Player::applyMovement(float delta_time) { clampToPlayfield(); const bool MOVED = (getPosX() != FRAME_START_X) || (getPosY() != FRAME_START_Y); - const bool ANIM_AS_MOVING = MOVED && (turning_remaining_ms_ <= 0.0F); + // `being_pushed_` força `stand_*`: el jugador es desplaça sense input, + // animar-lo com a caminant faria creure que està caminant quan de + // fet és arrossegat per l'altre. + const bool ANIM_AS_MOVING = MOVED && (turning_remaining_ms_ <= 0.0F) && !being_pushed_; setCurrentAnimation(animationName(ANIM_AS_MOVING)); // Detecció de canvi de tile. Es calcula *després* del clamp i de les diff --git a/source/game/entities/player.hpp b/source/game/entities/player.hpp index 00de35b..4c17985 100644 --- a/source/game/entities/player.hpp +++ b/source/game/entities/player.hpp @@ -87,6 +87,14 @@ class Player : public AnimatedSprite { // contra mur) s'emparen automàticament. void setOtherPlayers(const std::vector* all) { all_players_ = all; } + // Marcat pel resolver jugador↔jugador quan aquest frame el jugador + // és arrossegat per una empenta (no té input propi; es desplaça + // perquè l'altre l'empeny). Determina que l'animació del frame siga + // `stand_*` encara que la posició canvie. El flag es reseteja a + // l'inici de cada `tick`, així que el resolver l'ha de tornar a + // posar cada frame que el push s'haja de mantenir. + void setBeingPushed(bool pushed) { being_pushed_ = pushed; } + // Y de la base del jugador (cara inferior de la caixa de colisió). Usada // per al Z-order contra els sarcs: peus per sobre d'un sarc → darrere; // per sota → davant. @@ -162,6 +170,11 @@ class Player : public AnimatedSprite { // Punter a la llista de tots els jugadors del nivell (propietat del // GameScene). `nullptr` = jugador aïllat, sense col·lisió inter-P. const std::vector* all_players_{nullptr}; + + // Frame actual el jugador està sent empentat (no té input propi). + // Serveix només per a l'elecció d'animació — la posició ja la + // gestiona `intended_vx_/vy_` escalats pel resolver. + bool being_pushed_{false}; // Còpia plana de la geometria del mapa + punters a `collides`/ // `inside_playfield`. POD trivial; copiada al ctor i no muta durant // la vida del Player. Vol dir que aquest Player només és vàlid per diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index 363dd49..629c9b8 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -130,6 +130,9 @@ namespace { const float VY = pusher->getIntendedVY() * push_factor; pusher->setIntendedVelocity(VX, VY); pushed->setIntendedVelocity(VX, VY); + // El pushed es mourà sense input propi: marquem-ho perquè + // `applyMovement` no active la variant `walk` de l'animació. + pushed->setBeingPushed(true); } // Ordre d'`applyMovement`: en push, el pushed ha de moure's abans From 858e444842c9a15459c6bae4bcfdda24c6d43f3f Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Fri, 24 Apr 2026 19:23:30 +0200 Subject: [PATCH 10/10] =?UTF-8?q?feat(player):=20hit-box=208=C3=978=20per?= =?UTF-8?q?=20a=20contacte=20actor-actor=20(player,=20momia,=20trampa)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/player/morcus.yaml | 5 +++ data/player/sam.yaml | 5 +++ .../entities/entity_manager_loadlevel.cpp | 6 ++- source/game/entities/player.cpp | 41 +++++++++++++++++-- source/game/entities/player.hpp | 26 +++++++++++- source/game/scenes/game_scene.cpp | 9 +++- 6 files changed, 84 insertions(+), 8 deletions(-) diff --git a/data/player/morcus.yaml b/data/player/morcus.yaml index 5146dbc..5d92dd0 100644 --- a/data/player/morcus.yaml +++ b/data/player/morcus.yaml @@ -10,6 +10,11 @@ frameHeight: 32 collision: size: 16 base_size: 16 + # Caixa de contacte actor-actor (jugador↔jugador, jugador↔momia/trampa). + # Més petita que `size` perquè els contactes d'acció siguin precisos; + # `size` es reserva per a la col·lisió amb murs/sarcòfags i per enfilar + # els passadissos. Centrada en el mateix punt que la caixa base. + hit_size: 8 animations: - name: stand_down diff --git a/data/player/sam.yaml b/data/player/sam.yaml index 233506b..b59e98f 100644 --- a/data/player/sam.yaml +++ b/data/player/sam.yaml @@ -10,6 +10,11 @@ frameHeight: 32 collision: size: 16 base_size: 16 + # Caixa de contacte actor-actor (jugador↔jugador, jugador↔momia/trampa). + # Més petita que `size` perquè els contactes d'acció siguin precisos; + # `size` es reserva per a la col·lisió amb murs/sarcòfags i per enfilar + # els passadissos. Centrada en el mateix punt que la caixa base. + hit_size: 8 animations: - name: stand_down diff --git a/source/game/entities/entity_manager_loadlevel.cpp b/source/game/entities/entity_manager_loadlevel.cpp index e1edc64..4d26778 100644 --- a/source/game/entities/entity_manager_loadlevel.cpp +++ b/source/game/entities/entity_manager_loadlevel.cpp @@ -65,7 +65,11 @@ void EntityManager::checkPlayerCollisions(const std::vector& players) { const auto BOX = entity->getCollisionRect(); for (auto* p : players) { if (p == nullptr) { continue; } - const auto PBOX = p->getBaseBox(); + // Contacte actor-actor amb hit-box petita: la caixa base + // 16×16 servia per moure (xoc amb murs), però per a l'event + // "toca una entitat" volem precisió — que la momia mate quan + // els píxels centrals es solapen, no quan els caps es freguen. + const auto PBOX = p->getHitBox(); const bool OVERLAP = PBOX.x + PBOX.w > BOX.x && BOX.x + BOX.w > PBOX.x && PBOX.y + PBOX.h > BOX.y && BOX.y + BOX.h > PBOX.y; if (OVERLAP) { entity->onPlayerContact(p); } } diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index dc8b54a..b33fc99 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -77,6 +77,14 @@ void Player::loadFrom(const std::string& yaml_path) { collision_x_offset = -collision_padding; collision_y_offset = (sprite_h - base_size) - collision_padding; + // Hit-box opcional (actor-actor). Si no hi és, s'iguala a la base + // per preservar compatibilitat amb YAMLs antics. Concèntrica amb la + // caixa de col·lisió: offset interior = (collision_size - hit_size) / 2. + hit_size = c.contains("hit_size") ? c["hit_size"].get_value() : collision_size; + const int HIT_INSET = (collision_size - hit_size) / 2; + hit_x_offset = collision_x_offset + HIT_INSET; + hit_y_offset = collision_y_offset + HIT_INSET; + // Bloc opcional `turn:` — si manca, `duration_ms` queda a 0 i la // mecànica del gir de 180° no s'activa (canvis de direcció instantanis). turn_config = TurnConfig{}; @@ -248,10 +256,16 @@ void Player::update(float delta_time) { auto Player::collidesWithWorld(const SDL_FRect& box) const -> bool { if (map_.collides(box)) { return true; } if (all_players_ == nullptr) { return false; } - return std::ranges::any_of(*all_players_, [this, &box](const Player* p) { + // 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 + // amb la hit-box actual de l'altre jugador. Així la caixa de moviment + // (16×16) segueix topant amb murs/sarcs però els jugadors poden + // acostar-se fins al contacte real. + const SDL_FRect PROPOSED_HIT = hitBoxFromBase(box); + return std::ranges::any_of(*all_players_, [this, &PROPOSED_HIT](const Player* p) { if (p == nullptr || p == this) { return false; } - const auto OTHER = p->getBaseBox(); - return SDL_HasRectIntersectionFloat(&box, &OTHER); + const auto OTHER = p->getHitBox(); + return SDL_HasRectIntersectionFloat(&PROPOSED_HIT, &OTHER); }); } @@ -397,6 +411,27 @@ auto Player::getBaseBox() const -> SDL_FRect { }; } +auto Player::getHitBox() const -> SDL_FRect { + return { + .x = getPosX() + static_cast(hit_x_offset), + .y = getPosY() + static_cast(hit_y_offset), + .w = static_cast(hit_size), + .h = static_cast(hit_size), + }; +} + +auto Player::hitBoxFromBase(const SDL_FRect& base) -> SDL_FRect { + // Desplaçament intern respecte al cantó de la caixa base: mateixos + // offsets que `getHitBox()` però partint d'una caixa arbitrària. + const int INSET = (collision_size - hit_size) / 2; + return { + .x = base.x + static_cast(INSET), + .y = base.y + static_cast(INSET), + .w = static_cast(hit_size), + .h = static_cast(hit_size), + }; +} + void Player::snapToLane( float delta_time, float base, diff --git a/source/game/entities/player.hpp b/source/game/entities/player.hpp index 4c17985..a450587 100644 --- a/source/game/entities/player.hpp +++ b/source/game/entities/player.hpp @@ -40,6 +40,17 @@ class Player : public AnimatedSprite { inline static int collision_x_offset = 0; inline static int collision_y_offset = 0; + // Caixa de "contacte" més petita que la caixa de col·lisió: s'usa per + // a les interaccions actor-actor (player↔player, player↔momia/trampa), + // on volem que el contacte sigui precís i no dispari a "distància" + // de la vora del sprite. La caixa de col·lisió gran continua usant-se + // per xocar amb murs i sarcòfags (per mantenir el carril del + // passadís). Sense `hit_size` al YAML, s'iguala a `collision_size` i + // el comportament és idèntic a l'anterior. + inline static int hit_size = 0; + inline static int hit_x_offset = 0; + inline static int hit_y_offset = 0; + // Ha de cridar-se abans d'instanciar Player; GameScene ho fa al ctor. static void loadFrom(const std::string& yaml_path); @@ -115,10 +126,21 @@ class Player : public AnimatedSprite { [[nodiscard]] auto getCurrentTile() const -> std::pair; // Rectangle de col·lisió del jugador, en coordenades del canvas. Usat - // pel mapa de col·lisions i per les entitats que comproven contacte - // amb el jugador (momies, trampes, porta). + // pel mapa de col·lisions (tryMove contra murs/sarcòfags). [[nodiscard]] auto getBaseBox() const -> SDL_FRect; + // Rectangle de contacte actor-actor (hit-box), més petit que la caixa + // base i concèntric amb ella. L'usen `checkPlayerCollisions` + // (momies, trampes, porta) i el resolver jugador↔jugador per + // decidir quan es toquen. + [[nodiscard]] auto getHitBox() const -> SDL_FRect; + + // Versió estàtica: deriva la hit-box a partir d'una caixa base + // arbitrària (p. ex. la posició proposada per `tryMove` abans + // d'aplicar-la). Evita recalcular `hit_x_offset`/`hit_y_offset` fora + // de Player. + [[nodiscard]] static auto hitBoxFromBase(const SDL_FRect& base) -> SDL_FRect; + // Override del hook de Sprite: `MovingSprite::tryMove` envia aquesta // caixa al callback de col·lisió. La base del Player és més petita // que el sprite (el cap pot sobresortir per murs/sarcòfags sense diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index 629c9b8..d16ae04 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -98,8 +98,13 @@ namespace { auto* b = ps[1]; if (a == nullptr || b == nullptr) { return; } - const SDL_FRect BOX_A = a->getBaseBox(); - const SDL_FRect BOX_B = b->getBaseBox(); + // Calculem l'overlap previst en l'espai de la hit-box petita: + // els jugadors han de poder acostar-se molt (caixes de moviment + // 16×16 quasi tocant) sense disparar el resolver; només quan el + // contacte real (hit-box 8×8) s'activaria, neutralitzem o apliquem + // push. + const SDL_FRect BOX_A = a->getHitBox(); + const SDL_FRect BOX_B = b->getHitBox(); const SDL_FRect NEXT_A{ .x = BOX_A.x + (a->getIntendedVX() * dt), .y = BOX_A.y + (a->getIntendedVY() * dt),