#include "game/entities/player.hpp" #include #include #include #include #include #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; 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); } 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(); } } 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; } // 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. // // Filtre per altura: si aquest jugador (this) està en l'aire i passa // per damunt del cos de l'altre, no hi ha col·lisió — saltar permet // sobrevolar l'altre. `body_height` és estàtic (compartit entre // jugadors), però per al filtre val la pena consultar-lo per actor // perquè en el futur cada character podria tenir-ne un de propi. const SDL_FRect PROPOSED_HIT = hitBoxFromBase(box); const float Z = jump_z_; const bool AIRBORNE = (state_ == State::AIRBORNE); return std::ranges::any_of(*all_players_, [this, &PROPOSED_HIT, AIRBORNE, Z](const Player* p) { if (p == nullptr || p == this) { return false; } if (AIRBORNE && body_height > 0 && 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); } } 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; // 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; } // 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; } state_ = State::AIRBORNE; jump_z_ = 0.0F; jump_vz_ = jump_config.impulse_px_per_s; // 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::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); return true; } return false; } void Player::resolveLandingOverlap() { if (all_players_ == nullptr) { return; } for (Player* other : *all_players_) { if (other == nullptr || other == this) { continue; } const SDL_FRect MY_HIT = getHitBox(); const SDL_FRect OT_HIT = other->getHitBox(); SDL_FRect inter; if (!SDL_GetRectIntersectionFloat(&MY_HIT, &OT_HIT, &inter)) { continue; } // Eix de separació: el que té els centres més separats. És // l'axis-aligned MTV "natural" — equivalent a empentar l'altre // cap a fora seguint la direcció centre→centre, projectada al // costat dominant. Coincideix amb la intuïció: si el saltador // li cau una mica per sobre i a la dreta, l'altre va cap a la // dreta-baix; si li cau directament al damunt (dx≈0), l'altre // s'aparta verticalment. 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; float push_x = 0.0F; float push_y = 0.0F; if (std::fabs(DX) >= std::fabs(DY)) { push_x = (DX >= 0.0F ? 1.0F : -1.0F) * inter.w; } else { push_y = (DY >= 0.0F ? 1.0F : -1.0F) * inter.h; } // 1r intent: empentar l'altre. Si la nova posició no col·lisiona // amb res (mur, sarc, tercer jugador), commit i marquem-lo com // a empentat per coherència de l'animació de l'altre frame. const float OSX = other->getPosX(); const float OSY = other->getPosY(); other->setPosX(OSX + push_x); other->setPosY(OSY + push_y); if (!other->collidesWithWorld(other->getBaseBox())) { other->setBeingPushed(true); continue; } // L'altre xocaria amb un mur: revertim i provem d'apartar el // saltador cap al sentit oposat. Així respectem la regla "mai // ficar ningú dins d'un mur": si no hi ha camí per a l'altre, // qui es desplaça és el qui ha triat saltar. other->setPosX(OSX); other->setPosY(OSY); const float MSX = getPosX(); const float MSY = getPosY(); setPosX(MSX - push_x); setPosY(MSY - push_y); if (!collidesWithWorld(getBaseBox())) { continue; } // Cap dels dos pot apartar-se sense xocar amb un mur. Cas // degenerat (jugador encaixonat entre obstacles); el deixem com // estava i acceptem el solapament residual fins al pròxim frame // amb input. setPosX(MSX); setPosY(MSY); } } void Player::applyMovement(float delta_time) { // 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(); } // En LANDING_STUN no movem el jugador horitzontalment (la velocitat // ja s'ha posat a 0 al `tick`). Encara així animem (per estar al // frame correcte de la posada estàtica) i emetem el tile actual, // perquè la tile d'aterratge sí que es pinte. if (state_ == State::LANDING_STUN) { animate(delta_time); setCurrentAnimation(animationName(false)); 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_, }); } } 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. 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. // // En AIRBORNE NO publiquem cap pintat — el jugador està en l'aire i // no està trepitjant cap tile. NI tampoc actualitzem `last_tile_*`, // perquè volem que el tile d'aterratge (que es comprova al // LANDING_STUN) compari contra l'últim tile real (el del despegue) i // s'emeti com a entrada nova si han canviat. if (bus_ != nullptr && state_ == State::GROUNDED) { 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_, }); } } } void Player::render() { // 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)); const auto SY = static_cast(GROUND_Y) - SHADOW_H; // 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"; } 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), }; } 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; } 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); }