#include "game/entities/player.hpp" #include #include #include #include #include #include #include "core/audio/audio.hpp" #include "core/input/gamepad_manager.hpp" #include "core/input/input_state.hpp" #include "core/rendering/screen.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; // Llegeix la mida del frame des del bloc `sprite: { path, size: [w, h] }` // (mateix format que la resta de plantilles d'entitat, vegeu // AnimatedSprite). Extracció per mantenir `Player::loadFrom` sota el // llindar de complexitat cognitiva (25). void readSpriteSize(const fkyaml::node& yaml, const std::string& yaml_path, int& w, int& h) { if (!yaml.contains("sprite") || !yaml["sprite"].contains("size")) { throw std::runtime_error("Player: manca `sprite.size` a " + yaml_path); } const auto& size_node = yaml["sprite"]["size"]; w = size_node[0].get_value(); h = size_node[1].get_value(); } // 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; Player::InertiaConfig Player::inertia_config; Player::JumpConfig Player::jump_config; Player::HurtConfig Player::hurt_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); readSpriteSize(yaml, yaml_path, sprite_w, sprite_h); readCollisionBlock(yaml, yaml_path); // Bloc opcional `inertia:` — si manca, `brake_time_ms` queda a 0 i // la inèrcia de frenada no s'activa (parada instantània com abans). inertia_config = InertiaConfig{}; if (yaml.contains("inertia")) { const auto& i = yaml["inertia"]; if (i.contains("brake_time_ms")) { inertia_config.brake_time_ms = i["brake_time_ms"].get_value(); } if (i.contains("threshold_ms")) { inertia_config.threshold_ms = i["threshold_ms"].get_value(); } } // 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()); } } } // Bloc opcional `jump:` — si manca, `impulse_px_per_s` queda a 0 i el // botó de salt no fa res. Extret a un mètode estàtic per acotar la // complexitat cognitiva de `loadFrom` sota el llindar de clang-tidy. readJumpBlock(yaml); // Bloc opcional `hurt:` — estat post-impacte. Si manca o el speed // és 0, l'estat HURT mai s'activa (cas degenerat). readHurtBlock(yaml); } void Player::readCollisionBlock(const fkyaml::node& yaml, const std::string& yaml_path) { 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; // Hit-box opcional (actor-actor). Si no hi és, s'iguala a la base // per preservar compatibilitat amb YAMLs antics. Concèntrica amb la // caixa de col·lisió: offset interior = (collision_size - hit_size) / 2. hit_size = c.contains("hit_size") ? c["hit_size"].get_value() : collision_size; const int HIT_INSET = (collision_size - hit_size) / 2; hit_x_offset = collision_x_offset + HIT_INSET; hit_y_offset = collision_y_offset + HIT_INSET; // Alçada del cos, llegida del mateix bloc per coherència amb la // resta d'entitats (mummy, sarcophagus, etc.). 0 = sense filtre per // altura — el salt no esquiva l'altre jugador. body_height = c.contains("height") ? c["height"].get_value() : 0; } void Player::readJumpBlock(const fkyaml::node& yaml) { jump_config = JumpConfig{}; if (!yaml.contains("jump")) { return; } const auto& j = yaml["jump"]; if (j.contains("impulse")) { jump_config.impulse_px_per_s = j["impulse"].get_value(); } if (j.contains("gravity")) { jump_config.gravity_px_per_s2 = j["gravity"].get_value(); } if (j.contains("landing_stun_ms")) { jump_config.landing_stun_ms = j["landing_stun_ms"].get_value(); } if (j.contains("shadow_y_offset")) { jump_config.shadow_y_offset = j["shadow_y_offset"].get_value(); } // Sons opcionals: el nom del fitxer (relatiu a data/sounds/) val tant // de clau al Resource::Cache com de basename per a la càrrega. Si el // camp manca, la clau queda buida i `playSound` no es crida. if (j.contains("sound")) { jump_config.sound_key = j["sound"].get_value(); Resource::Cache::get().loadSound(jump_config.sound_key, "data/sounds/" + jump_config.sound_key); } if (j.contains("land_sound")) { jump_config.land_sound_key = j["land_sound"].get_value(); Resource::Cache::get().loadSound(jump_config.land_sound_key, "data/sounds/" + jump_config.land_sound_key); } } void Player::readHurtBlock(const fkyaml::node& yaml) { hurt_config = HurtConfig{}; if (!yaml.contains("hurt")) { return; } const auto& h = yaml["hurt"]; if (h.contains("speed")) { hurt_config.initial_speed_px_per_s = h["speed"].get_value(); } if (h.contains("brake_time_ms")) { hurt_config.brake_time_ms = h["brake_time_ms"].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 { // EXITED/DISABLED: jugador fora del joc. No xoca amb res ni res // xoca amb ell. També es filtren els altres jugadors fora de joc // al check actor↔actor més avall via el predicat `isOutOfPlay`. if (isOutOfPlay()) { return false; } 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. // // Regla de col·lisió jugador↔jugador (segons el feel triat): // - HURT a un costat o a l'altre → cap col·lisió. El jugador // impactat es travessa net. // - Tots dos a terra (PvP-tangibles) → col·lisió normal. // - Un en l'aire i l'altre no → SENSE col·lisió. El saltador es // travessa net del caminant; ningú actua de "muro" durant el // vol, ni a la pujada ni a la baixada. // - Tots dos en l'aire → col·lisió només si els rangs verticals // [jump_z, jump_z + body_height] solapen. Així permetem el xoc // mid-aire quan tots dos van un contra l'altre i estan a la // mateixa franja d'alçada. const SDL_FRect PROPOSED_HIT = hitBoxFromBase(box); const bool MY_AIR = (state_ == State::AIRBORNE); const bool MY_HURT = (state_ == State::HURT); const float MY_Z = jump_z_; return std::ranges::any_of(*all_players_, [this, &PROPOSED_HIT, MY_AIR, MY_HURT, MY_Z](const Player* p) { if (p == nullptr || p == this) { return false; } if (p->isOutOfPlay()) { return false; } if (MY_HURT || p->isHurt()) { return false; } if (MY_AIR != p->isAirborne()) { return false; } // Aquí els dos estats coincideixen. Si tots dos volen, el filtre // per altura mira que els rangs verticals encara solapin; si no, // estan a prou distància en Y "real" i no es toquen. if (MY_AIR && body_height > 0 && std::fabs(MY_Z - p->jump_z_) >= static_cast(body_height)) { return false; } const auto OTHER = p->getHitBox(); return SDL_HasRectIntersectionFloat(&PROPOSED_HIT, &OTHER); }); } void Player::integrateAxis(float& phys, float input, float delta_time, float motion_time_ms, bool any_input) { // Acceleració instantània: mentre hi ha input, la velocitat salta a // ±SPEED sense transició. Cobreix també els canvis de direcció (un // coast cap a la dreta passa a -SPEED immediatament si es prem esquerra). if (input != 0.0F) { phys = input; return; } if (phys == 0.0F) { return; } // Hi ha velocitat residual i aquest eix no té input. Tres camins: // · Input a l'altre eix (gir de 90°): parada en sec — no arrosseguem // en diagonal mentre l'usuari està premant una altra direcció. // · Input solta del tot i llindar superat: inèrcia (decaiment lineal). // · Input solta del tot i llindar NO superat: parada en sec (passets). if (any_input) { phys = 0.0F; return; } const bool INERTIA_ACTIVE = (inertia_config.brake_time_ms > 0) && (motion_time_ms >= static_cast(inertia_config.threshold_ms)); if (!INERTIA_ACTIVE) { phys = 0.0F; return; } const float DECEL = SPEED_PX_PER_S / (static_cast(inertia_config.brake_time_ms) / 1000.0F); const float STEP = DECEL * delta_time; if (std::fabs(phys) <= STEP) { phys = 0.0F; } else { phys -= std::copysign(STEP, phys); } } // Pipeline d'estats i input separats; extracció a helpers empitjoraria la // llegibilitat. La complexitat puja 1 per la guarda EXITED. // NOLINTNEXTLINE(readability-function-cognitive-complexity) void Player::tick(float delta_time) { // EXITED/DISABLED: jugador fora del joc, no fa res. Cap input, cap // física, cap bus event. Continua sent al vector de jugadors per // simplicitat (no s'esborra) però queda completament inert fins // que GameScene el reactiva (respawn / spawnFromAir). if (isOutOfPlay()) { return; } // 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; // En LANDING_STUN, descomptem el timer i ignorem qualsevol input // (moviment i salt). Quan s'esgota, tornem a GROUNDED al pròxim // frame. La velocitat queda a zero — després del stun hi tornarà // a haver intenció si el jugador prem alguna tecla. if (state_ == State::LANDING_STUN) { landing_stun_remaining_ms_ -= delta_time * 1000.0F; if (landing_stun_remaining_ms_ <= 0.0F) { landing_stun_remaining_ms_ = 0.0F; state_ = State::GROUNDED; } input_vx_ = 0.0F; input_vy_ = 0.0F; intended_vx_ = 0.0F; intended_vy_ = 0.0F; motion_time_ms_ = 0.0F; setVelX(0.0F); setVelY(0.0F); // Mantenim els flancs del botó de salt actualitzats fins i tot // durant el stun: així si l'usuari l'ha estat prement abans, no // se'l consumeix com a salt nou en sortir del stun. (void)consumeJumpEdge(); return; } // En AIRBORNE el jugador no llig input direccional: la velocitat // horitzontal queda fixada al moment del despegue. Tampoc canvia // facing. Sí actualitzem els flancs del botó perquè la propera // pulsació quan torne a terra es detecte com a flanc nou. if (state_ == State::AIRBORNE) { (void)consumeJumpEdge(); setVelX(intended_vx_); setVelY(intended_vy_); return; } // En HURT el jugador està projectat sense control: descomptem // velocitat per inèrcia (decaiment lineal fins a 0). Quan arriba a // zero, tornem a GROUNDED i el frame següent ja torna a llegir input. // El moviment real l'aplica `applyMovement` (igual que els altres // estats), que també gestiona el xoc contra murs. if (state_ == State::HURT) { (void)consumeJumpEdge(); const float DECEL = (hurt_config.brake_time_ms > 0) ? hurt_config.initial_speed_px_per_s / (static_cast(hurt_config.brake_time_ms) / 1000.0F) : 0.0F; const float STEP = DECEL * delta_time; auto decay = [STEP](float& v) { if (std::fabs(v) <= STEP) { v = 0.0F; } else { v -= std::copysign(STEP, v); } }; decay(intended_vx_); decay(intended_vy_); if (intended_vx_ == 0.0F && intended_vy_ == 0.0F) { state_ = State::GROUNDED; } input_vx_ = 0.0F; input_vy_ = 0.0F; setVelX(intended_vx_); setVelY(intended_vy_); return; } // 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); input_vx_ = vx; input_vy_ = vy; // Integració física per eix. `intended_v*` fa de memòria entre frames: // si l'usuari té input, se sobreescriu a ±SPEED; si no, decau per // inèrcia o es posa a 0. El `motion_time_ms_` que passem és el del // final del frame anterior — serveix per decidir si la parada d'aquest // frame és suau (llindar superat) o en sec. HAS_INPUT bloca la inèrcia // en girs de 90° (un eix a 0 amb l'altre actiu no ha de "patinar"). const bool HAS_INPUT = (input_vx_ != 0.0F) || (input_vy_ != 0.0F); integrateAxis(intended_vx_, input_vx_, delta_time, motion_time_ms_, HAS_INPUT); integrateAxis(intended_vy_, input_vy_, delta_time, motion_time_ms_, HAS_INPUT); // Actualitzem l'acumulador DESPRÉS de la integració. Creix mentre hi // haja intenció (input) o moviment residual (coast); només es buida // quan el jugador està genuïnament aturat (ni input ni velocitat). Així // un canvi de direcció enmig del coast o un toc curt contra el coast // no rearma mai el llindar. const bool HAS_VELOCITY = (intended_vx_ != 0.0F) || (intended_vy_ != 0.0F); if (HAS_INPUT || HAS_VELOCITY) { motion_time_ms_ += delta_time * 1000.0F; } else { motion_time_ms_ = 0.0F; } setVelX(intended_vx_); setVelY(intended_vy_); // Important: el facing i la detecció de gir de 180° s'alimenten amb // input CRU, no amb la velocitat física. Un coast sense input NO // canvia el facing (la darrera orientació es preserva), i els girs // només disparen amb intenció explícita del jugador. updateFacingAndTurn(delta_time, input_vx_, input_vy_); // Detecció de flanc del botó de salt. Es processa DESPRÉS d'aplicar // el facing perquè el sprite del salt usa el facing del moment del // despegue. `being_pushed_` no ho pot bloquejar ací (encara no s'ha // marcat aquest frame, ho fa el resolver entre tick i applyMovement); // ho gestionem deixant que el resolver vinga, i si en el moment del // despegue (consumeJumpEdge → startJump) ja estem AIRBORNE, l'altre // braç del switch no canvia res. if (consumeJumpEdge() && jump_config.impulse_px_per_s > 0.0F) { startJump(); } } auto Player::consumeJumpEdge() -> bool { // Edge-detection independent per teclat i mando: si qualsevol dels // dos passa de no-premut a premut aquest frame, comptem com a salt. // Sense detecció de flanc, mantenir la tecla provocaria saltar // immediatament en aterrar. const bool* keys = InputState::getKeyboardState(); const bool KEY_NOW = keys[controls_.jump]; bool edge = (KEY_NOW && !jump_held_keyboard_); jump_held_keyboard_ = KEY_NOW; SDL_Gamepad* pad = GamepadManager::get().getForPlayer(id_); const bool PAD_NOW = (pad != nullptr) && InputState::getGamepadButton(pad, SDL_GAMEPAD_BUTTON_SOUTH); if (PAD_NOW && !jump_held_gamepad_) { edge = true; } jump_held_gamepad_ = PAD_NOW; return edge; } void Player::startJump() { // No saltem si l'estat no ho permet. `being_pushed_` no és segur de // consultar ací (es pot haver marcat o no, segons l'ordre del frame), // així que la regla és: només GROUNDED arrenca salts. El push viu // entre `tick` i `applyMovement`, així que si el resolver acaba // empentant en mig d'un frame on intentàvem saltar, el flanc es perd // — comportament acceptable: l'empenta guanya al toc curt. if (state_ != State::GROUNDED) { return; } // Un gir de 180° en curs es compromet immediatament: si l'usuari // ha disparat un giro (oest→est, p.ex.) i salta abans que s'esgote // el timer, l'animació del salt ha de mostrar la direcció final // (jump lateral), no la intermèdia (jump_down). Aplem el pending // ara mateix i resetem l'estat del gir. Si no hi havia gir en // curs, és no-op. if (turning_remaining_ms_ > 0.0F) { applyCardinal(pending_cardinal_); turning_remaining_ms_ = 0.0F; pending_cardinal_ = Cardinal::NONE; intermediate_cardinal_ = Cardinal::NONE; } state_ = State::AIRBORNE; jump_z_ = 0.0F; jump_vz_ = jump_config.impulse_px_per_s; if (!jump_config.sound_key.empty() && Audio::get() != nullptr) { Audio::get()->playSound(jump_config.sound_key, Audio::Group::GAME); } // Avisem al bus perquè el GameScene puga spawnejar el polvet de // despegue al peu del jugador. if (bus_ != nullptr) { bus_->publish(Event{ .name = "player_jumped", .payload = WorldPos{.x = getPosX() + (static_cast(sprite_w) * 0.5F), .y = getGroundFeetY()}, .player_id = id_, }); } // Prou: la velocitat horitzontal del salt és la `intended_v*` actual // (input del frame). El facing també queda com està. Ja no es // tocaran fins a aterrar. } auto Player::deltasFor(Cardinal c) -> std::pair { switch (c) { case Cardinal::EAST: return {1.0F, 0.0F}; case Cardinal::WEST: return {-1.0F, 0.0F}; case Cardinal::NORTH: return {0.0F, -1.0F}; case Cardinal::SOUTH: return {0.0F, 1.0F}; case Cardinal::NONE: break; } return {0.0F, 0.0F}; } auto Player::reachInDirection(float dx, float dy, int max_px) const -> int { const auto BASE = getBaseBox(); for (int i = 1; i <= max_px; ++i) { SDL_FRect probe = BASE; probe.x += static_cast(i) * dx; probe.y += static_cast(i) * dy; if (map_.collides(probe) || !map_.inside_playfield(probe)) { return i - 1; } } return max_px; } auto Player::chooseHurtDirection(Cardinal preferred) const -> Cardinal { constexpr int MAX_PX = 32; // 2 tiles, prou per a la cerca; la inèrcia limita el recorregut real. // Recorregut natural del HURT (distància que recórreria sota // decaiment lineal pur). Si la direcció preferida cabe sense topar // abans d'aquesta distància, l'usem; d'altra manera, busquem la // millor. const auto REQUIRED = static_cast( 0.5F * hurt_config.initial_speed_px_per_s * (static_cast(hurt_config.brake_time_ms) / 1000.0F)); if (preferred != Cardinal::NONE) { const auto [DX, DY] = deltasFor(preferred); if (reachInDirection(DX, DY, MAX_PX) >= REQUIRED) { return preferred; } } constexpr std::array ALL{Cardinal::EAST, Cardinal::WEST, Cardinal::NORTH, Cardinal::SOUTH}; int best_reach = -1; Cardinal best_dir = Cardinal::EAST; for (Cardinal c : ALL) { const auto [DX, DY] = deltasFor(c); const int R = reachInDirection(DX, DY, MAX_PX); if (R > best_reach) { best_reach = R; best_dir = c; } } return best_dir; } void Player::enterHurt(Cardinal preferred) { // Si la mecànica no està configurada (speed = 0), no fem res — el // comportament queda equivalent al d'abans, sense empenta. if (hurt_config.initial_speed_px_per_s <= 0.0F || hurt_config.brake_time_ms <= 0) { return; } const Cardinal CHOSEN = chooseHurtDirection(preferred); // Activem l'estat i fixem la velocitat segons la direcció triada. state_ = State::HURT; applyCardinal(CHOSEN); last_input_cardinal_ = CHOSEN; turning_remaining_ms_ = 0.0F; pending_cardinal_ = Cardinal::NONE; intermediate_cardinal_ = Cardinal::NONE; being_pushed_ = false; const float V = hurt_config.initial_speed_px_per_s; const auto [DX, DY] = deltasFor(CHOSEN); intended_vx_ = DX * V; intended_vy_ = DY * V; input_vx_ = 0.0F; input_vy_ = 0.0F; motion_time_ms_ = 0.0F; } auto Player::integrateJump(float delta_time) -> bool { if (state_ != State::AIRBORNE) { return false; } jump_vz_ -= jump_config.gravity_px_per_s2 * delta_time; jump_z_ += jump_vz_ * delta_time; if (jump_z_ <= 0.0F) { jump_z_ = 0.0F; jump_vz_ = 0.0F; state_ = State::LANDING_STUN; landing_stun_remaining_ms_ = static_cast(jump_config.landing_stun_ms); if (!jump_config.land_sound_key.empty() && Audio::get() != nullptr) { // Consumeix el preset armat per GameScene al spawn aeri // d'INTRO (single-shot): el primer LAND el sona reverberat, // els salts següents cauen al camí sec. if (next_land_reverb_preset_.empty()) { Audio::get()->playSound(jump_config.land_sound_key, Audio::Group::GAME); } else { Audio::get()->playSoundWithReverb(jump_config.land_sound_key, next_land_reverb_preset_, Audio::Group::GAME); next_land_reverb_preset_.clear(); } } if (bus_ != nullptr) { bus_->publish(Event{ .name = "player_landed", .payload = WorldPos{.x = getPosX() + (static_cast(sprite_w) * 0.5F), .y = getGroundFeetY()}, .player_id = id_, }); } return true; } return false; } void Player::resolveLandingOverlap() { // Quan el saltador (this) aterra al damunt d'un altre jugador, el // saltador NO es desplaça — es queda al lloc en LANDING_STUN. En // canvi, qualsevol jugador amb el qual solape entra en HURT i // surt projectat. Així el solapament inicial es resol per moviment // del solapat, no per desplaçament forçat al lloc. if (all_players_ == nullptr) { return; } for (Player* other : *all_players_) { if (other == nullptr || other == this) { continue; } // Salta jugadors fora del joc: un caigut sobre un slot DISABLED // (o sobre un EXITED encara al vector) no els ha de projectar. if (other->isOutOfPlay()) { continue; } // Test amb la hit-box (8×8 de contacte precís), no la base-box. // Coherent amb la resta de col·lisions actor-actor del joc: // momies, fuetades futures, resolver PvP… tots usen hit-box. Un // aterratge "rozant" (cossos solapats però els punts de contacte // separats) no dispara HURT — el frame següent els dos estan a // terra i la col·lisió normal els gestiona. const SDL_FRect MY_HIT = getHitBox(); const SDL_FRect OT_HIT = other->getHitBox(); if (!SDL_HasRectIntersectionFloat(&MY_HIT, &OT_HIT)) { continue; } // Direcció preferida del HURT: cap on l'altre està respecte a // nosaltres (cardinal dominant del vector centre→centre this → // other). Si caem a la dreta del seu cos, ell està a l'esquerra // nostra → preferred = WEST → surt cap a l'esquerra. Si caem // exactament al damunt (centres alineats), preferred queda // NONE i `enterHurt` busca la millor direcció lliure. const float MCX = MY_HIT.x + (MY_HIT.w * 0.5F); const float MCY = MY_HIT.y + (MY_HIT.h * 0.5F); const float OCX = OT_HIT.x + (OT_HIT.w * 0.5F); const float OCY = OT_HIT.y + (OT_HIT.h * 0.5F); const float DX = OCX - MCX; const float DY = OCY - MCY; Cardinal preferred = Cardinal::NONE; if (std::fabs(DX) >= std::fabs(DY) && DX != 0.0F) { preferred = (DX > 0.0F) ? Cardinal::EAST : Cardinal::WEST; } else if (DY != 0.0F) { preferred = (DY > 0.0F) ? Cardinal::SOUTH : Cardinal::NORTH; } other->enterHurt(preferred); } } void Player::applyMovement(float delta_time) { // EXITED/DISABLED: cap moviment ni col·lisió. Idèntic al gating de `tick`. if (isOutOfPlay()) { return; } // Integració del salt: si estem AIRBORNE, avança jump_z_/jump_vz_. // El facing i intended_v* es mantenen del moment del despegue (no es // recalculen). Si toquem el sòl aquest frame, transicionem a // LANDING_STUN i resolem el possible solapament amb un altre // jugador que estigués sota — apartem-lo (o, si no es pot, ens // apartem nosaltres). if (integrateJump(delta_time)) { resolveLandingOverlap(); } if (state_ == State::HURT) { applyHurtMovement(delta_time); return; } if (state_ == State::LANDING_STUN) { applyLandingStunMovement(delta_time); return; } // 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. En l'aire // no fem snap: la trajectòria queda fixada al despegue, que el snap // mid-flight desviaria. const bool AIRBORNE = (state_ == State::AIRBORNE); const bool ONLY_H = !AIRBORNE && (VX != 0.0F) && (VY == 0.0F); const bool ONLY_V = !AIRBORNE && (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); // `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. AIRBORNE força `jump_*` (vegeu // `animationName`), independentment de la velocitat. const bool ANIM_AS_MOVING = MOVED && (turning_remaining_ms_ <= 0.0F) && !being_pushed_; setCurrentAnimation(animationName(ANIM_AS_MOVING)); // Detecció de canvi de tile. Només publiquem en GROUNDED — en // AIRBORNE el jugador no trepitja, i en HURT/LANDING_STUN les // pròpies branques del switch ja crida la helper si toca. if (state_ == State::GROUNDED) { publishTileEnteredIfChanged(); } } void Player::applyHurtMovement(float delta_time) { // En HURT el jugador es desplaça segons `intended_v*` (escrit a // `enterHurt` i decremental a `tick`). Si `tryMove` no avança // perquè topa amb un mur, el parem en sec i tornem a GROUNDED — // honra la regla "salvo col·lisió, llavors para". animate(delta_time); const float FRAME_START_X = getPosX(); const float FRAME_START_Y = getPosY(); tryMove(intended_vx_ * delta_time, intended_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 WANTED_TO_MOVE = (intended_vx_ != 0.0F) || (intended_vy_ != 0.0F); if (!MOVED && WANTED_TO_MOVE) { // Bloquejat per un mur → parem en sec i recuperem el control. intended_vx_ = 0.0F; intended_vy_ = 0.0F; state_ = State::GROUNDED; } setCurrentAnimation(animationName(true)); publishTileEnteredIfChanged(); } void Player::applyLandingStunMovement(float delta_time) { // En LANDING_STUN no movem (la velocitat ja és 0). Animem per // mantenir el frame del stun i emetem la tile d'aterratge perquè // es pinte. animate(delta_time); setCurrentAnimation(animationName(false)); publishTileEnteredIfChanged(); } void Player::publishTileEnteredIfChanged() { if (bus_ == nullptr) { return; } const auto [COL, ROW] = getCurrentTile(); if (COL == last_tile_col_ && ROW == last_tile_row_) { return; } 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_, }); } void Player::render() { // EXITED/DISABLED: invisible. La crida al render base es salta // sencera; el jugador no apareix al z-sort tot i seguir vivint al // vector. if (isOutOfPlay()) { return; } // Mentre el jugador està enlaire, el sprite es desplaça visualment // cap amunt `jump_z_` píxels. Modifiquem `pos_.y` (la posició entera // de render) abans de cridar el render base, i la restaurem després // perquè l'estat seguent del frame ho recalcule des de `y_` (la font // de veritat float). El sòl sota el jugador no es pinta des d'ací: // això ho fa el GameScene en una passada de sombres a banda. if (jump_z_ <= 0.0F) { AnimatedSprite::render(); return; } const float SAVED_Y = pos_.y; pos_.y = SAVED_Y - jump_z_; AnimatedSprite::render(); pos_.y = SAVED_Y; } void Player::renderShadow() const { // Sense salt actiu, no hi ha sombra: això simplifica els callsites, // que poden cridar `renderShadow` per a tots els jugadors sense // discriminar. if (!isAirborne()) { return; } // Sombra centrada al sprite, recolzada a la línia dels peus reals // (ignorant `jump_z_`). L'amplada es redueix proporcional a l'altura // del salt — al pic queda més estreta, donant la sensació de // "elevació" sense necessitat de coordenades 3D reals. Si el // jump_config té un divisor zero (cas degenerat), només dibuixem // la sombra a mida plena per evitar nan. const auto PEAK = (jump_config.gravity_px_per_s2 > 0.0F) ? (jump_config.impulse_px_per_s * jump_config.impulse_px_per_s) / (2.0F * jump_config.gravity_px_per_s2) : 0.0F; const float NORM_Z = (PEAK > 0.0F) ? std::clamp(jump_z_ / PEAK, 0.0F, 1.0F) : 0.0F; constexpr float MAX_SHRINK = 0.4F; // Fins a un 40% més estreta al pic. const float SHRINK = 1.0F - (NORM_Z * MAX_SHRINK); constexpr int SHADOW_H = 3; const auto SHADOW_W = static_cast(static_cast(base_size) * SHRINK); const float CX = getPosX() + (static_cast(sprite_w) * 0.5F); const float GROUND_Y = getGroundFeetY(); const auto SX = static_cast(CX - (static_cast(SHADOW_W) * 0.5F)); // Pugem la sombra `shadow_y_offset` px per compensar la franja // transparent que el sprite del jugador té sota els peus reals // (perquè la silueta camine pel mig del passadís en falsa // perspectiva). Sense aquest ajust, la sombra apareix per sota // del passadís. const auto SY = static_cast(GROUND_Y) - SHADOW_H - jump_config.shadow_y_offset; // ARGB negre amb alpha ~40%. `blendRect` mescla sobre el canvas; així // la sombra fosqueja el sòl (pintat o no) sense tapar-lo del tot. constexpr std::uint32_t SHADOW_ARGB = 0x00000000U; constexpr std::uint8_t SHADOW_ALPHA = 100; Screen::get().blendRect(SX, SY, SHADOW_W, SHADOW_H, SHADOW_ARGB, SHADOW_ALPHA); } auto Player::animationName(bool moving) const -> const char* { // En l'aire forcem la variant `jump_*` (per direcció), independent de // si hi ha velocitat horitzontal: per al jugador, l'estat visual del // salt és per direcció, no per velocitat. if (state_ == State::AIRBORNE) { switch (facing_) { case Facing::UP: return "jump_up"; case Facing::DOWN: return "jump_down"; case Facing::SIDE: break; } return "jump"; } // En LANDING_STUN, `stun_*` (per direcció): mostrem el frame // d'aterratge fins que s'esgota el timer. La direcció ve fixada del // moment del despegue (no es canvia mentre estem en l'aire). if (state_ == State::LANDING_STUN) { switch (facing_) { case Facing::UP: return "stun_up"; case Facing::DOWN: return "stun_down"; case Facing::SIDE: break; } return "stun"; } // En HURT, `hurt_*` (per direcció): el jugador llisca sense // control en la direcció triada per `enterHurt`. Mateixa estructura // que walk per facilitat (ara mateix els frames són clons). if (state_ == State::HURT) { switch (facing_) { case Facing::UP: return "hurt_up"; case Facing::DOWN: return "hurt_down"; case Facing::SIDE: break; } return "hurt"; } 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); 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); // Passadís de sortida: si la base box queda enterament dins del rang // X del passadís de la porta, deixem que pugi al strip del mur trasser // (fins a `door_corridor.y`). Fora del rang, MIN_Y es queda al // playfield. La regla "enterament dins" exigeix alinear-se al passadís // abans de pujar — la base box (12 px) hi cap amb 4 px de marge. if (map_.door_corridor_w > 0) { const float BASE_X = getPosX() + static_cast(collision_x_offset); const float BASE_RIGHT = BASE_X + static_cast(collision_size); const auto CORRIDOR_LEFT = static_cast(map_.door_corridor_x); const auto CORRIDOR_RIGHT = static_cast(map_.door_corridor_x + map_.door_corridor_w); if (BASE_X >= CORRIDOR_LEFT && BASE_RIGHT <= CORRIDOR_RIGHT) { min_y = static_cast(map_.door_corridor_y - collision_y_offset); } } setPosX(std::clamp(getPosX(), MIN_X, MAX_X)); setPosY(std::clamp(getPosY(), min_y, MAX_Y)); } void Player::respawn(float x, float y) { state_ = State::GROUNDED; intended_vx_ = 0.0F; intended_vy_ = 0.0F; input_vx_ = 0.0F; input_vy_ = 0.0F; being_pushed_ = false; jump_z_ = 0.0F; jump_vz_ = 0.0F; landing_stun_remaining_ms_ = 0.0F; turning_remaining_ms_ = 0.0F; pending_cardinal_ = Cardinal::NONE; intermediate_cardinal_ = Cardinal::NONE; last_input_cardinal_ = Cardinal::NONE; // Reseteig del facing al d'spawn original (mirant cap a la càmera): // sense això, un EXITED que entrava per la porta amb facing UP // re-aparix d'esquena al pròxim cicle. Mateixa lògica que el ctor. facing_ = Facing::DOWN; setFlip(SDL_FLIP_NONE); setCurrentAnimation("stand_down"); setPosX(x); setPosY(y); } void Player::disable() { if (state_ == State::DISABLED) { return; } state_ = State::DISABLED; intended_vx_ = 0.0F; intended_vy_ = 0.0F; input_vx_ = 0.0F; input_vy_ = 0.0F; being_pushed_ = false; jump_z_ = 0.0F; jump_vz_ = 0.0F; landing_stun_remaining_ms_ = 0.0F; turning_remaining_ms_ = 0.0F; pending_cardinal_ = Cardinal::NONE; intermediate_cardinal_ = Cardinal::NONE; last_input_cardinal_ = Cardinal::NONE; } void Player::spawnFromAir(float x, float y) { // Net total: equival a un `respawn` seguit d'una entrada en l'aire. // Així el flag de gir, l'animació, els flancs del salt i les // velocitats residuals queden a un estat conegut abans de promocionar // a AIRBORNE. Si hi havia un cardinal d'input penjat de la sessió // anterior, no contamina la pròxima caiguda. respawn(x, y); // Altura derivada de la geometria: prou per arrencar amb el sprite // sencer per damunt del bord superior del canvas (recordem que // visualment es pinta a `pos_.y - jump_z_`). Afegim un xicotet marge // perquè el primer frame ja siga clarament off-screen, no just al ras. constexpr float OFFSCREEN_MARGIN_PX = 8.0F; const float SPAWN_HEIGHT = y + static_cast(sprite_h) + OFFSCREEN_MARGIN_PX; state_ = State::AIRBORNE; jump_z_ = SPAWN_HEIGHT; // Caiguda recta: sense impuls inicial, deixem que la gravetat (la // mateixa que la del salt normal) faça la feina. Velocitat horitzontal // a zero perquè no es desviï del passadís d'spawn. jump_vz_ = 0.0F; intended_vx_ = 0.0F; intended_vy_ = 0.0F; // Mirant cap avall durant la caiguda — `animationName(AIRBORNE)` amb // facing DOWN ja resol a `jump_down`. `respawn` ja ha posat facing a // DOWN; ho deixem explícit ací per claredat de la cinemàtica. facing_ = Facing::DOWN; setCurrentAnimation("jump_down"); } 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), }; } auto Player::getHitBox() const -> SDL_FRect { return { .x = getPosX() + static_cast(hit_x_offset), .y = getPosY() + static_cast(hit_y_offset), .w = static_cast(hit_size), .h = static_cast(hit_size), }; } auto Player::hitBoxFromBase(const SDL_FRect& base) -> SDL_FRect { // Desplaçament intern respecte al cantó de la caixa base: mateixos // offsets que `getHitBox()` però partint d'una caixa arbitrària. const int INSET = (collision_size - hit_size) / 2; return { .x = base.x + static_cast(INSET), .y = base.y + static_cast(INSET), .w = static_cast(hit_size), .h = static_cast(hit_size), }; } void Player::snapToLane( float delta_time, float base, 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) { return; } const float FULL_STEP = std::min(std::abs(DELTA), SPEED_PX_PER_S * delta_time); const float SAVED = (this->*get_pos)(); // Fase 1: snap a velocitat plena. Si no xoca amb res, commit — és el // cas habitual (carril lliure) i no ha de patir la friccó de l'empenta. (this->*set_pos)(SAVED + std::copysign(FULL_STEP, DELTA)); if (!collidesWithWorld(baseBox())) { return; } // Mur al carril objectiu: no hi ha ningú a qui empentar, revert directe. if (map_.collides(baseBox())) { (this->*set_pos)(SAVED); return; } // Fase 2: sols un altre jugador al carril. Reintentem amb step escalat // per `push_factor_` (mateixa friccó que el push horitzontal del // resolver) i mirem d'arrossegar els blockers. Si la pushada no cap // (contra mur o tercer jugador), el snap es revertix sencer. const float PUSH_STEP = std::min(std::abs(DELTA), SPEED_PX_PER_S * push_factor_ * delta_time); const float SIGNED_PUSH = std::copysign(PUSH_STEP, DELTA); (this->*set_pos)(SAVED + SIGNED_PUSH); if (collidesWithWorld(baseBox())) { if (map_.collides(baseBox()) || !tryPushSnapBlockers(SIGNED_PUSH, get_pos, set_pos)) { (this->*set_pos)(SAVED); } } } auto Player::tryPushSnapBlockers( float signed_step, float (Player::*get_pos)() const, void (Player::*set_pos)(float)) -> bool { if (all_players_ == nullptr) { return false; } // Blockers = jugadors que ara mateix solapen la nostra hit-box (ja // amb el snap aplicat). Mateix criteri actor-actor que `collidesWithWorld`. std::vector blockers; const SDL_FRect MY_HIT = getHitBox(); for (Player* p : *all_players_) { if (p == nullptr || p == this) { continue; } if (p->isOutOfPlay()) { continue; } const SDL_FRect OTHER_HIT = p->getHitBox(); if (SDL_HasRectIntersectionFloat(&MY_HIT, &OTHER_HIT)) { blockers.push_back(p); } } if (blockers.empty()) { return false; } // Desem la posició original de cada blocker i el desplacem el mateix // step que s'ha aplicat a `this`: com que els dos es mouen igual en el // mateix eix, la distància relativa es conserva i, per tant, si abans // del snap no es solapaven, després de l'empenta tampoc. std::vector saved; saved.reserve(blockers.size()); for (Player* p : blockers) { const float S = (p->*get_pos)(); saved.push_back(S); (p->*set_pos)(S + signed_step); } // Validació: `this` ha de quedar net (ni mur ni cap jugador), i cada // blocker també (cobreix el cas de 3+ jugadors on un blocker podria // acabar dins d'un tercer o contra un mur). const bool OK = !collidesWithWorld(baseBox()) && std::ranges::none_of(blockers, [](const Player* p) { return p->collidesWithWorld(p->getBaseBox()); }); if (!OK) { for (size_t i = 0; i < blockers.size(); ++i) { (blockers[i]->*set_pos)(saved[i]); } return false; } // Els blockers s'han desplaçat sense input propi: mateixa convenció // d'animació que el push horitzontal del resolver (stand_*, no walk). for (Player* p : blockers) { p->setBeingPushed(true); } return true; } 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); }