Merge branch 'p2-support': suport del segon jugador
This commit is contained in:
commit
3aa92b5cb7
29 files changed
+1101
-118
No files matched your search
@@ -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"
|
||||
@@ -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 |
@@ -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)
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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)
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
// 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_KEY, SPAWN_X, SPAWN_Y, CONTROLS_P1, Map::makeInfo(), P1_PAINT));
|
||||
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
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user