#include "game/entities/player.hpp" #include #include #include #include #include #include "core/input/gamepad_manager.hpp" #include "core/input/input_state.hpp" #include "core/resources/resource_cache.hpp" #include "core/resources/resource_helper.hpp" #include "external/fkyaml_node.hpp" #include "game/entities/event_bus.hpp" #include "utils/defaults.hpp" namespace { // Alias local per reduir soroll; la constant viu a Defaults::Player. constexpr float SPEED_PX_PER_S = Defaults::Player::SPEED_PX_PER_S; // Dead-zone del stick analògic esquerre (rang Sint16: -32768..32767). // Converteix eix analògic en direcció digital; per sota, es considera // repòs i no contribueix a la velocitat. constexpr Sint16 STICK_DEADZONE = 12000; // 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; } if (InputState::getGamepadButton(pad, SDL_GAMEPAD_BUTTON_DPAD_UP)) { vy -= SPEED_PX_PER_S; } if (InputState::getGamepadButton(pad, SDL_GAMEPAD_BUTTON_DPAD_DOWN)) { vy += SPEED_PX_PER_S; } const Sint16 LX = InputState::getGamepadAxis(pad, SDL_GAMEPAD_AXIS_LEFTX); const Sint16 LY = InputState::getGamepadAxis(pad, SDL_GAMEPAD_AXIS_LEFTY); if (LX < -STICK_DEADZONE) { vx -= SPEED_PX_PER_S; } if (LX > STICK_DEADZONE) { vx += SPEED_PX_PER_S; } if (LY < -STICK_DEADZONE) { vy -= SPEED_PX_PER_S; } if (LY > STICK_DEADZONE) { vy += SPEED_PX_PER_S; } } } // namespace Player::TurnConfig Player::turn_config; void Player::loadFrom(const std::string& yaml_path) { auto bytes = Resource::Helper::loadFile(yaml_path); if (bytes.empty()) { throw std::runtime_error("Player: no s'ha pogut obrir " + yaml_path); } const auto* begin = reinterpret_cast(bytes.data()); const auto* end = begin + bytes.size(); auto yaml = fkyaml::node::deserialize(begin, end); sprite_w = yaml["frameWidth"].get_value(); sprite_h = yaml["frameHeight"].get_value(); if (!yaml.contains("collision")) { throw std::runtime_error("Player: manca bloc `collision:` a " + yaml_path); } const auto& c = yaml["collision"]; collision_size = c["size"].get_value(); base_size = c["base_size"].get_value(); // Col·lisió centrada en el quadrat inferior del sprite. Si la caixa és // més gran que la base (cas habitual, 16 vs 12) el padding és simètric // a cada costat; si són iguals, padding=0 i la caixa coincideix amb la // base sense offsets. collision_padding = (collision_size - base_size) / 2; collision_x_offset = -collision_padding; collision_y_offset = (sprite_h - base_size) - collision_padding; // 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{}; if (yaml.contains("turn")) { const auto& t = yaml["turn"]; if (t.contains("duration_ms")) { turn_config.duration_ms = t["duration_ms"].get_value(); } if (t.contains("transitions")) { const auto& tr = t["transitions"]; if (tr.contains("west_to_east")) { turn_config.w_to_e = cardinalFromString(tr["west_to_east"].get_value()); } if (tr.contains("east_to_west")) { turn_config.e_to_w = cardinalFromString(tr["east_to_west"].get_value()); } if (tr.contains("north_to_south")) { turn_config.n_to_s = cardinalFromString(tr["north_to_south"].get_value()); } if (tr.contains("south_to_north")) { turn_config.s_to_n = cardinalFromString(tr["south_to_north"].get_value()); } } } } void Player::applyCardinal(Cardinal c) { switch (c) { case Cardinal::NORTH: facing_ = Facing::UP; setFlip(SDL_FLIP_NONE); break; case Cardinal::SOUTH: facing_ = Facing::DOWN; setFlip(SDL_FLIP_NONE); break; case Cardinal::EAST: facing_ = Facing::SIDE; setFlip(SDL_FLIP_NONE); break; case Cardinal::WEST: facing_ = Facing::SIDE; setFlip(SDL_FLIP_HORIZONTAL); break; case Cardinal::NONE: break; } } auto Player::cardinalFromInput(float vx, float vy) -> Cardinal { // Mateixa precedència que la lògica original: l'horitzontal preval // sobre el vertical (els diagonals s'interpreten com a laterals). if (vx > 0.0F) { return Cardinal::EAST; } if (vx < 0.0F) { return Cardinal::WEST; } if (vy < 0.0F) { return Cardinal::NORTH; } if (vy > 0.0F) { return Cardinal::SOUTH; } return Cardinal::NONE; } auto Player::cardinalFromString(const std::string& s) -> Cardinal { if (s == "north" || s == "up") { return Cardinal::NORTH; } if (s == "south" || s == "down") { return Cardinal::SOUTH; } if (s == "east" || s == "right") { return Cardinal::EAST; } if (s == "west" || s == "left") { return Cardinal::WEST; } throw std::runtime_error("Player: cardinal desconegut '" + s + "' al bloc turn.transitions"); } auto Player::opposite(Cardinal c) -> Cardinal { switch (c) { case Cardinal::NORTH: return Cardinal::SOUTH; case Cardinal::SOUTH: return Cardinal::NORTH; case Cardinal::EAST: return Cardinal::WEST; case Cardinal::WEST: return Cardinal::EAST; case Cardinal::NONE: return Cardinal::NONE; } return Cardinal::NONE; } auto Player::intermediateFor(Cardinal target) -> Cardinal { switch (target) { case Cardinal::EAST: return turn_config.w_to_e; case Cardinal::WEST: return turn_config.e_to_w; case Cardinal::SOUTH: return turn_config.n_to_s; case Cardinal::NORTH: return turn_config.s_to_n; case Cardinal::NONE: return Cardinal::NONE; } return Cardinal::NONE; } void Player::updateFacingAndTurn(float delta_time, float vx, float vy) { // Cardinal d'INPUT cru. Important: la detecció del gir de 180° s'ha // de basar en l'input (intent del jugador), no en la velocitat // resultant. Mentre no hi haja inèrcia, vx/vy són directament l'input // i serveixen com a tal; quan s'introduïsca, cal preservar aquesta // separació (calcular el cardinal a partir de les tecles/gamepad // crus, no de la velocitat suavitzada). const Cardinal INPUT_CARD = cardinalFromInput(vx, vy); // Si hi ha un gir en curs, descomptem el timer i mantenim el facing // forçat al cardinal intermedi. Quan s'esgota, apliquem el cardinal // pendent. El moviment físic continua sense alterar-se (vx/vy ja // s'han enviat a setVel al caller). if (turning_remaining_ms_ > 0.0F) { turning_remaining_ms_ -= delta_time * 1000.0F; if (turning_remaining_ms_ <= 0.0F) { turning_remaining_ms_ = 0.0F; applyCardinal(pending_cardinal_); pending_cardinal_ = Cardinal::NONE; intermediate_cardinal_ = Cardinal::NONE; } else { applyCardinal(intermediate_cardinal_); } } else { // Detecció del gir: `last_input_cardinal_` és l'últim cardinal // d'input no nul (es preserva entre releases). Així, el primer // input des de l'estat idle no dispara cap gir — només dispara // quan el jugador "canvia d'opinió" prement la direcció oposada // a la prèvia. Requereix també que el bloc `turn:` del YAML // estiga activat (`duration_ms > 0`). const bool TRIGGER_TURN = (turn_config.duration_ms > 0) && INPUT_CARD != Cardinal::NONE && last_input_cardinal_ != Cardinal::NONE && INPUT_CARD == opposite(last_input_cardinal_); if (TRIGGER_TURN) { pending_cardinal_ = INPUT_CARD; intermediate_cardinal_ = intermediateFor(INPUT_CARD); turning_remaining_ms_ = static_cast(turn_config.duration_ms); applyCardinal(intermediate_cardinal_); } else if (vx != 0.0F) { // Tria de facing original: prevalença lateral, flip segons el // signe de vx; verticals purs van a UP/DOWN amb flip NONE; en // aturar-se conservem `facing_` per a l'animació stand. facing_ = Facing::SIDE; setFlip(vx < 0.0F ? SDL_FLIP_HORIZONTAL : SDL_FLIP_NONE); } else if (vy < 0.0F) { facing_ = Facing::UP; setFlip(SDL_FLIP_NONE); } else if (vy > 0.0F) { facing_ = Facing::DOWN; setFlip(SDL_FLIP_NONE); } } // Memoritzem l'últim cardinal d'input no nul per a la propera detecció // de gir (ignorant els zeros: l'estat idle no reinicia la memòria). if (INPUT_CARD != Cardinal::NONE) { last_input_cardinal_ = INPUT_CARD; } } 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); // 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; } return std::ranges::any_of(*all_players_, [this, &box](const Player* p) { if (p == nullptr || p == this) { return false; } const auto OTHER = p->getBaseBox(); return SDL_HasRectIntersectionFloat(&box, &OTHER); }); } void Player::tick(float delta_time) { // Via `InputState` en lloc de `SDL_GetKeyboardState` directe: quan el // service menu (o qualsevol UI modal) està obert, el wrapper retorna // un buffer tot-a-zero perquè el jugador no es mogui mentre l'usuari // navega el menú. Polling-blocked; veure `core/input/input_state.hpp`. const bool* keys = InputState::getKeyboardState(); float vx = 0.0F; float vy = 0.0F; if (keys[controls_.left]) { vx -= SPEED_PX_PER_S; } if (keys[controls_.right]) { vx += SPEED_PX_PER_S; } if (keys[controls_.up]) { vy -= SPEED_PX_PER_S; } if (keys[controls_.down]) { vy += SPEED_PX_PER_S; } // 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. 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. const bool ONLY_H = (VX != 0.0F) && (VY == 0.0F); const bool ONLY_V = (VY != 0.0F) && (VX == 0.0F); const float FRAME_START_X = getPosX(); const float FRAME_START_Y = getPosY(); if (ONLY_H) { snapToHorizontalLane(delta_time); } else if (ONLY_V) { snapToVerticalLane(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, 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 collidesWithWorld(box); }); clampToPlayfield(); const bool MOVED = (getPosX() != FRAME_START_X) || (getPosY() != FRAME_START_Y); const bool ANIM_AS_MOVING = MOVED && (turning_remaining_ms_ <= 0.0F); setCurrentAnimation(animationName(ANIM_AS_MOVING)); // Detecció de canvi de tile. Es calcula *després* del clamp i de les // col·lisions perquè la posició aquí ja és la definitiva del frame; // així el PaintLayer no rep falsos positius mentre el Player rellisca // contra una paret. Si el tile ha canviat, publiquem `on_tile_entered` // al bus amb l'índex de pintat d'aquest jugador; el PaintLayer filtra // pintabilitat i re-publica `tile_painted` als sarcòfags. if (bus_ != nullptr) { const auto [COL, ROW] = getCurrentTile(); if (COL != last_tile_col_ || ROW != last_tile_row_) { last_tile_col_ = COL; last_tile_row_ = ROW; bus_->publish(Event{ .name = "on_tile_entered", .payload = PaintedTile{.col = COL, .row = ROW, .tile_index = paint_tile_index_}, .player_id = id_, }); } } } auto Player::animationName(bool moving) const -> const char* { switch (facing_) { case Facing::UP: return moving ? "walk_up" : "stand_up"; case Facing::DOWN: return moving ? "walk_down" : "stand_down"; case Facing::SIDE: break; } return moving ? "walk" : "stand"; } auto Player::getCurrentTile() const -> std::pair { // El grid del tilemap és de `tile_size` píxels i està 16-alineat // respecte a l'origen del tilemap (pot ser negatiu per overscan). El // centre de la caixa de col·lisió ens dóna el tile on "està" el Player. const auto BOX = baseBox(); const float CX = BOX.x + (BOX.w * 0.5F); const float CY = BOX.y + (BOX.h * 0.5F); const int COL = static_cast(std::floor( (CX - static_cast(map_.tilemap_origin_x)) / static_cast(map_.tile_size))); const int ROW = static_cast(std::floor( (CY - static_cast(map_.tilemap_origin_y)) / static_cast(map_.tile_size))); return {COL, ROW}; } void Player::clampToPlayfield() { // Límits de la zona jugable: la caixa de col·lisió no pot eixir del // playfield (muros). El cap del sprite sí pot sobresortir per damunt. const auto MIN_X = static_cast(map_.playfield_x - collision_x_offset); const auto MAX_X = static_cast( map_.playfield_x + map_.playfield_w - collision_x_offset - collision_size); const auto MIN_Y = static_cast(map_.playfield_y - collision_y_offset); const auto MAX_Y = static_cast( map_.playfield_y + map_.playfield_h - collision_y_offset - collision_size); setPosX(std::clamp(getPosX(), MIN_X, MAX_X)); setPosY(std::clamp(getPosY(), MIN_Y, MAX_Y)); } auto Player::getBaseBox() const -> SDL_FRect { return { .x = getPosX() + static_cast(collision_x_offset), .y = getPosY() + static_cast(collision_y_offset), .w = static_cast(collision_size), .h = static_cast(collision_size), }; } void Player::snapToLane( float delta_time, float base, float pitch, int max_idx, float (Player::*get_pos)() const, void (Player::*set_pos)(float)) { // Lanes en pos_axis: base + i·pitch per a i ∈ [0, max_idx]. Correspon a // la caixa de col·lisió ocupant exactament un passadís perpendicular. const auto THRESHOLD = static_cast(collision_size) / 2.0F; const auto IDX = std::clamp( static_cast(std::round(((this->*get_pos)() - base) / pitch)), 0, max_idx); const float LANE = base + (static_cast(IDX) * pitch); const float DELTA = LANE - (this->*get_pos)(); if (std::abs(DELTA) > 0.0F && std::abs(DELTA) <= THRESHOLD) { const float STEP = std::min(std::abs(DELTA), SPEED_PX_PER_S * delta_time); const float SAVED = (this->*get_pos)(); (this->*set_pos)(SAVED + std::copysign(STEP, DELTA)); if (map_.collides(baseBox())) { (this->*set_pos)(SAVED); } } } void Player::snapToHorizontalLane(float delta_time) { snapToLane(delta_time, static_cast(map_.playfield_y - collision_y_offset), static_cast(map_.pitch_y), map_.grid_rows, &Player::getPosY, &Player::setPosY); } void Player::snapToVerticalLane(float delta_time) { snapToLane(delta_time, static_cast(map_.playfield_x - collision_x_offset), static_cast(map_.pitch_x), map_.grid_cols, &Player::getPosX, &Player::setPosX); }