#include "game/entities/player.hpp" #include #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 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(); 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 void Player::loadFrom(const std::string& yaml_path) { const auto FULL = Resource::Helper::resolve(yaml_path); std::ifstream file(FULL); if (!file.is_open()) { throw std::runtime_error("Player: no s'ha pogut obrir " + FULL); } auto yaml = fkyaml::node::deserialize(file); 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 " + FULL); } 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; } Player::Player(const std::string& anim_key, float x, float y, const Controls& controls, const Map::Info& map_info) : AnimatedSprite(Resource::Cache::get().getAnimationData(anim_key)), controls_(controls), map_(map_info) { setPos(x, y); setCurrentAnimation("stand"); } void Player::update(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 primer gamepad (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); setVelX(vx); setVelY(vy); // Tria de facing amb prevalença lateral: si hi ha component horitzontal // (inclòs el diagonal), l'animació és lateral i el flip segueix el signe // de `vx`. Només en moviment purament vertical passem a UP/DOWN, i en // aquest cas el flip es força a NONE perquè els sprites frontals no // s'han d'espillar segons l'últim lateral. En aturar-se conservem // `facing_` per a l'animació stand corresponent. if (vx != 0.0F) { 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); } // 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); // 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(); 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 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. animate(delta_time); tryMove(vx * delta_time, vy * delta_time, [this](const SDL_FRect& box) { return map_.collides(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. const bool MOVED = (getPosX() != FRAME_START_X) || (getPosY() != FRAME_START_Y); setCurrentAnimation(animationName(MOVED)); // 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í els sarcòfags no reben falsos positius mentre el Player rellisca // contra una paret. Si el tile ha canviat, publiquem `on_tile_entered` // al bus; l'EntityManager farà fan-out a notifyTileEntered. 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("on_tile_entered", TilePos{.col = COL, .row = ROW}); } } } 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); }