Merge branch 'p2-support': suport del segon jugador

This commit is contained in:
JailDesigner committed 2026-04-24 19:30:32 +02:00
commit 3aa92b5cb7
29 files changed
+1102 -119

No files matched your search

+5
View File
@@ -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"
+8 -2
View File
@@ -18,8 +18,14 @@ 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
# 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
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

+54
View File
@@ -0,0 +1,54 @@
# player animation
tileSetFile: morcus.gif
frameWidth: 16
frameHeight: 32
# Geometria de col·lisió del jugador (independent de l'animació):
# size → costat del quadrat de col·lisió (16×16)
# base_size → costat del quadrat visual inferior del sprite (els "peus");
# s'usa per centrar la caixa de col·lisió sobre ell.
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
frames: [0]
- name: stand_up
frames: [4]
- name: stand
frames: [8]
- name: walk_down
speed: 0.1
loopFrom: 0
frames: [0, 1, 2, 3]
- name: walk_up
speed: 0.1
loopFrom: 0
frames: [4, 5, 6, 7]
- name: walk
speed: 0.1
loopFrom: 0
frames: [8, 9, 10, 11]
# Gir de 180°: mentre dura, el facing queda fixat a la direcció
# intermèdia (efecte purament visual; el moviment continua sense
# alterar-se). Els girs de 90° no passen per cap intermedi.
turn:
duration_ms: 80
# Direcció intermèdia per a cada gir de 180° (sentit horari).
transitions:
west_to_east: down # passa per baix (stand_down)
east_to_west: up # passa per dalt (stand_up)
north_to_south: east # passa per la dreta (stand, sense flip)
south_to_north: west # passa per l'esquerra (stand, amb flip)
+5
View File
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

+135 -3
View File
@@ -2,6 +2,7 @@
#include <algorithm>
#include <iostream>
#include <iterator>
#include <string>
#include <utility>
@@ -78,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);
@@ -87,8 +99,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 +127,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 +197,99 @@ 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<Info> {
std::vector<Info> 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<std::size_t>(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<std::size_t>(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<std::size_t>(player_id)] = Assignment{};
}
auto GamepadManager::autoAssignFreeSlots() -> std::vector<AutoAssigned> {
std::vector<AutoAssigned> assigned;
for (int pid = 0; pid < MAX_PLAYERS; ++pid) {
auto& slot = assignments_[static_cast<std::size_t>(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;
}
+65 -3
View File
@@ -2,6 +2,7 @@
#include <SDL3/SDL.h>
#include <array>
#include <cstddef>
#include <string>
#include <vector>
@@ -41,22 +42,83 @@ 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<Info>;
// 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);
// 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<AutoAssigned>;
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<Assignment, MAX_PLAYERS> 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;
+9 -2
View File
@@ -176,12 +176,19 @@ Director::Director() {
const auto DEBUG_YAML = system_folder_ + "debug.yaml";
DebugConfig::createDefaultIfMissing(DEBUG_YAML);
const auto START = DebugConfig::resolveStartScene(DEBUG_YAML);
const auto STARTING_PLAYERS = DebugConfig::resolveStartingPlayers(DEBUG_YAML);
#else
const auto START = DebugConfig::StartScene::LOGO;
const auto STARTING_PLAYERS = DebugConfig::StartingPlayers::BOTH;
#endif
std::cout << "Director: start_scene=" << DebugConfig::toString(START) << '\n';
std::cout << "Director: start_scene=" << DebugConfig::toString(START)
<< " starting_players=" << DebugConfig::toString(STARTING_PLAYERS) << '\n';
auto make_game = []() -> std::unique_ptr<Scene> { return std::make_unique<GameScene>(); };
auto make_game = [STARTING_PLAYERS]() -> std::unique_ptr<Scene> {
auto cfg = GameScene::defaultConfig();
cfg.starting_players = STARTING_PLAYERS;
return std::make_unique<GameScene>(std::move(cfg));
};
auto make_intro = [placeholder_text, make_game]() -> std::unique_ptr<Scene> {
return std::make_unique<IntroScene>(placeholder_text, make_game);
};
+52 -2
View File
@@ -33,6 +33,17 @@ namespace DebugConfig {
return name == "LOGO" || name == "TITLE" || name == "INTRO" || name == "GAME";
}
auto playersFromName(const std::string& name) -> 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<std::string>();
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
+14
View File
@@ -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
+9 -2
View File
@@ -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;
}
+9 -2
View File
@@ -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>() : 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)
+9 -2
View File
@@ -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)
@@ -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)
+9 -2
View File
@@ -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>() : 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)
@@ -65,7 +65,11 @@ void EntityManager::checkPlayerCollisions(const std::vector<Player*>& 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); }
}
+5
View File
@@ -45,6 +45,11 @@ struct Event {
std::string name;
using Payload = std::variant<std::monostate, int, std::string, Entity*, TilePos, PaintedTile>;
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 {
+104 -33
View File
@@ -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; }
@@ -74,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<int>() : 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{};
@@ -222,16 +233,48 @@ 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) {
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; }
// 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->getHitBox();
return SDL_HasRectIntersectionFloat(&PROPOSED_HIT, &OTHER);
});
}
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
@@ -244,31 +287,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(vx, vy);
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();
@@ -279,22 +323,24 @@ 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);
// `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
@@ -308,7 +354,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_,
});
}
}
}
@@ -361,6 +411,27 @@ auto Player::getBaseBox() const -> SDL_FRect {
};
}
auto Player::getHitBox() const -> SDL_FRect {
return {
.x = getPosX() + static_cast<float>(hit_x_offset),
.y = getPosY() + static_cast<float>(hit_y_offset),
.w = static_cast<float>(hit_size),
.h = static_cast<float>(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<float>(INSET),
.y = base.y + static_cast<float>(INSET),
.w = static_cast<float>(hit_size),
.h = static_cast<float>(hit_size),
};
}
void Player::snapToLane(
float delta_time,
float base,
+95 -3
View File
@@ -5,6 +5,7 @@
#include <cstdint>
#include <string>
#include <utility>
#include <vector>
#include "core/rendering/sprite/animated_sprite.hpp"
#include "game/map/map.hpp"
@@ -39,13 +40,72 @@ 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);
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);
// 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<Player*>* 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.
@@ -66,10 +126,21 @@ class Player : public AnimatedSprite {
[[nodiscard]] auto getCurrentTile() const -> std::pair<int, int>;
// 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
@@ -81,6 +152,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();
@@ -104,7 +180,23 @@ class Player : public AnimatedSprite {
float (Player::*get_pos)() const,
void (Player::*set_pos)(float));
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<Player*>* 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
+10 -3
View File
@@ -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<PaintedTile>(&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<std::size_t>(row)][static_cast<std::size_t>(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<std::size_t>(row)][static_cast<std::size_t>(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;
}
+1 -1
View File
@@ -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;
+74 -2
View File
@@ -9,6 +9,7 @@
#include <string_view>
#include <utility>
#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<Options::PostFXPreset> g_postfx_presets{};
std::vector<Options::CrtPiPreset> g_crtpi_presets{};
@@ -160,6 +162,43 @@ 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) → auto-assign per ordre
// els slots que hagen quedat buits (equivalent al patró de
// `coffee_crisis_arcade_edition`).
void applyControlsToGamepadManager() {
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) {
if (!yaml.contains("audio")) { return; }
const auto& node = yaml["audio"];
@@ -442,6 +481,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<PostFXPreset>& { return g_postfx_presets; }
@@ -513,6 +553,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;
}
@@ -522,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();
}
@@ -548,6 +601,17 @@ namespace Options {
parseWindow(yaml);
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';
@@ -594,7 +658,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());
}
+20
View File
@@ -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<PostFXPreset>&;
@@ -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);
+196 -27
View File
@@ -62,6 +62,106 @@ namespace {
return yaml[key].get_value<int>();
}
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<const char*>(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<float>();
}
// 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<Player*>& 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; }
// 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),
.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);
// 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
// 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<Player*>& ps) -> std::vector<Player*> {
std::vector<Player*> 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 {
@@ -69,7 +169,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 +181,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 +217,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<int>(&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 +250,31 @@ GameScene::GameScene(Config config)
static_cast<float>(Map::playfield_y) + (static_cast<float>(Map::outer_corridor) / 2.0F);
const auto SPAWN_X = CORRIDOR_CENTER_X - (static_cast<float>(Player::sprite_w) / 2.0F);
const auto SPAWN_Y = CORRIDOR_CENTER_Y - static_cast<float>(Player::collision_y_offset) - (static_cast<float>(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<float>(Map::tile_size) : SPAWN_X;
const auto P2_SPAWN_X = BOTH ? SPAWN_X + static_cast<float>(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<Player>(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<Player>(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<Player>(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 +284,20 @@ GameScene::GameScene(Config config)
// manager per subscriure's a `on_tile_entered` i publicar `tile_painted`.
paint_layer_ = std::make_unique<PaintLayer>(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()); }
// 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
@@ -151,6 +308,10 @@ GameScene::GameScene(Config config)
auto hud_font = std::make_shared<Text>(hud_font_surface, UiConfig::get().hudFont().fnt);
hud_font->setDefaultKerning(UiConfig::get().hudFont().kerning);
hud_ = std::make_unique<Hud>(entity_manager_.getBus(), hud_font);
// Si hi ha P2 actiu, activem el seu bloc (per defecte el HUD naix amb
// només P1). Mentre els events del bus no porten `player_id`, els hits
// segueixen aplicant-se a P1 (veure TODO a Hud::onPlayerHit).
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
@@ -287,7 +448,21 @@ void GameScene::update(int delta_ms) {
const int SCALED_DELTA_MS = static_cast<int>(static_cast<float>(delta_ms) * pause_.scale);
if (!EDITOR_FROZEN && SCALED_DELTA_MS > 0) {
const float DT = static_cast<float>(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
@@ -295,15 +470,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*> 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
+19 -2
View File
@@ -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;
@@ -63,6 +68,18 @@ class GameScene : public Scene {
// té els seus propis Controls i entra al vector amb una crida extra.
std::vector<std::unique_ptr<Player>> 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<Player*> 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
+10 -6
View File
@@ -114,16 +114,20 @@ Hud::Hud(EventBus& bus, std::shared_ptr<Text> 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<int>(&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<std::size_t>(BlockId::LEFT)
: static_cast<std::size_t>(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';
}));
+150 -19
View File
@@ -7,6 +7,7 @@
#include <utility>
#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"
@@ -34,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
@@ -731,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",
@@ -749,17 +753,149 @@ 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;
}
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<float>(font.length(text, kerning));
const auto AVG_CHAR_W = TOTAL_W / static_cast<float>(text.size());
auto keep = static_cast<std::size_t>(static_cast<float>(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
// 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<GamepadManager::Info> {
const auto ALL = GamepadManager::get().getAll();
const auto& other = (player_id == 0) ? Options::controls().gamepad_p2
: Options::controls().gamepad_p1;
std::vector<GamepadManager::Info> 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 = 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; }
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;
}
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 = 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") + ')';
}
void applyPadSelection(int player_id, std::size_t idx) {
if (idx == 0) {
Options::setGamepadAssignment(player_id, "", "");
return;
}
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);
}
} // 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 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 visiblePads(1).size() + 1; },
[](std::size_t idx) { applyPadSelection(1, idx); },
[](std::size_t idx) { return padLabel(1, idx); }));
return page;
}
@@ -945,13 +1081,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;
+21
View File
@@ -80,8 +80,24 @@ 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).
// 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.
@@ -209,6 +225,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