#include "game/entities/player.hpp" #include #include #include #include #include #include #include "core/audio/audio.hpp" #include "core/input/gamepad_bindings.hpp" #include "core/input/gamepad_manager.hpp" #include "core/input/input_state.hpp" #include "core/rendering/canvas.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/entity.hpp" #include "game/entities/entity_manager.hpp" #include "game/entities/event_bus.hpp" #include "game/options.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; Player::WhipConfig Player::whip_config; Player::DeathConfig Player::death_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); // Bloc opcional `whip:` — paràmetres del látigo. Si manca, el // jugador no atacarà efectivament (ATTACKING dura 0 ms i el flanc // d'input es perd). readWhipBlock(yaml); // Bloc opcional `death:` — pausa de respawn i comptador inicial de // vides. Si manca, el respawn és immediat i les vides queden a 3 // per defecte (no és un valor crític: avui no hi ha game over). readDeathBlock(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(); } // So d'entrada a HURT — mateix patró que `jump.sound` i `whip.sound`. if (h.contains("sound")) { hurt_config.sound_key = h["sound"].get_value(); Resource::Cache::get().loadSound(hurt_config.sound_key, "data/sounds/" + hurt_config.sound_key); } } void Player::readDeathBlock(const fkyaml::node& yaml) { death_config = DeathConfig{}; if (!yaml.contains("death")) { return; } const auto& d = yaml["death"]; if (d.contains("respawn_delay_ms")) { death_config.respawn_delay_ms = d["respawn_delay_ms"].get_value(); } if (d.contains("lives")) { death_config.initial_lives = d["lives"].get_value(); } } void Player::readWhipBlock(const fkyaml::node& yaml) { whip_config = WhipConfig{}; if (!yaml.contains("whip")) { return; } const auto& w = yaml["whip"]; if (w.contains("range_tiles")) { whip_config.range_tiles = w["range_tiles"].get_value(); } if (w.contains("swing_ms")) { whip_config.swing_ms = w["swing_ms"].get_value(); } if (w.contains("cooldown_ms")) { whip_config.cooldown_ms = w["cooldown_ms"].get_value(); } if (w.contains("damage")) { whip_config.damage = w["damage"].get_value(); } // So del swing: mateix patró que `jump.sound` — basename val de // clau al cache i de path. Si manca, queda buit i no es dispara. if (w.contains("sound")) { whip_config.sound_key = w["sound"].get_value(); Resource::Cache::get().loadSound(whip_config.sound_key, "data/sounds/" + whip_config.sound_key); } } void Player::applyCardinal(Cardinal c) { // El flip del sprite el governa l'animació al motor: només cal posar // el `facing_` que tocaria; `animationName()` ja resol el sufix // (`_west` per Cardinal::WEST) i el YAML hi declara `flip_h: true`. switch (c) { case Cardinal::NORTH: facing_ = Facing::NORTH; break; case Cardinal::SOUTH: facing_ = Facing::SOUTH; break; case Cardinal::EAST: facing_ = Facing::EAST; break; case Cardinal::WEST: facing_ = Facing::WEST; 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") { return Cardinal::NORTH; } if (s == "south") { return Cardinal::SOUTH; } if (s == "east") { return Cardinal::EAST; } if (s == "west") { 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: prevalença lateral, EAST/WEST segons el signe // de vx; verticals purs van a NORTH/SOUTH. En aturar-se // conservem `facing_` per a l'animació stand. facing_ = (vx < 0.0F) ? Facing::WEST : Facing::EAST; } else if (vy < 0.0F) { facing_ = Facing::NORTH; } else if (vy > 0.0F) { facing_ = Facing::SOUTH; } } // 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 SOUTH i l'animació `stand_south` abans del primer // frame d'input — el primer `update` la preservarà si l'usuari no // prem cap tecla. facing_ = Facing::SOUTH; setCurrentAnimation("stand_south"); // Comptador d'intents inicialitzat amb el valor del YAML (default 3 // si el bloc `death:` manca). Fora del cicle DYING no toca a ningú. lives_remaining_ = death_config.initial_lives; // Spawn point inicial: la posició del ctor val per defecte; el // GameScene el reescriu via `setSpawnPoint` quan canvia de sala. spawn_x_ = x; spawn_y_ = y; } 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_; const bool MY_DYING = (state_ == State::DYING); return std::ranges::any_of(*all_players_, [this, &PROPOSED_HIT, MY_AIR, MY_HURT, MY_DYING, MY_Z](const Player* p) { if (p == nullptr || p == this) { return false; } if (p->isOutOfPlay()) { return false; } // DYING és intangible per a PvP: el jugador mort no actua de // mur, i un altre jugador en DYING no pot empentar tampoc. if (MY_DYING || p->isDying()) { 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; } // DYING: animació `death_spinning` + pausa de respawn. Ignorem tot // l'input (ni moviment, ni salt, ni atac). El moviment queda // congelat al lloc de la mort; `applyMovement` avança l'animació i // detecta la fi per disparar el respawn. Mantenim els flancs de // botó actualitzats perquè una tecla sostinguda no es comporte com // a flanc nou en sortir del cicle. if (state_ == State::DYING) { (void)consumeJumpEdge(); (void)consumeAttackEdge(); 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); 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(); (void)consumeAttackEdge(); 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(); (void)consumeAttackEdge(); setVelX(intended_vx_); setVelY(intended_vy_); return; } // En ATTACKING el jugador queda bloquejat tot el `swing_ms + // cooldown_ms`: ni input direccional, ni salt, ni atac, ni canvi // de facing. El timer descompta amb `delta_time * 1000` per // mantenir la convenció de delta_ms de la resta d'estats temporals. // Mantenim els flancs dels botons actualitzats perquè una // tecla mantinguda no es comporte com a flanc nou en sortir. if (state_ == State::ATTACKING) { (void)consumeJumpEdge(); (void)consumeAttackEdge(); attacking_remaining_ms_ -= delta_time * 1000.0F; if (attacking_remaining_ms_ <= 0.0F) { attacking_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); 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(); (void)consumeAttackEdge(); 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; } // Refresca les vinculacions des d'Options en calent: així un remap fet // al menú de servei té efecte immediatament, sense esperar al pròxim // GameScene::onEnter. Replica el comportament d'AEE // (`KeyRemap::update` que llig `Options::keys_game` cada frame). refreshControlsFromOptions(); // 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ó d'atac. Es processa ABANS del salt // perquè ATTACKING i AIRBORNE són mútuament excloents i, davant un // mateix-frame, l'atac té prioritat (millor castigar la pulsació // doble per excés que perdre l'atac per un toc accidental al salt). // En entrar a ATTACKING, descartem el flanc del salt per no que // queda penjat per a quan es desbloque. if (consumeAttackEdge() && whip_config.swing_ms > 0) { enterAttacking(currentFacingCardinal()); (void)consumeJumpEdge(); return; } // 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(); } } void Player::refreshControlsFromOptions() { if (id_ != 0 && id_ != 1) { return; } const auto& kb = (id_ == 0) ? Options::controls().keyboard_p1 : Options::controls().keyboard_p2; controls_.up = kb.up; controls_.down = kb.down; controls_.left = kb.left; controls_.right = kb.right; controls_.jump = kb.jump; controls_.attack = kb.attack; } 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 auto& slot = (id_ == 0) ? Options::controls().gamepad_p1 : Options::controls().gamepad_p2; const int JUMP_BTN = GamepadBindings::get().getButton(slot.name, slot.path, GamepadBindings::Action::JUMP); const bool PAD_NOW = (pad != nullptr) && !InputState::isBlocked() && GamepadBindings::isPressed(pad, JUMP_BTN); if (PAD_NOW && !jump_held_gamepad_) { edge = true; } jump_held_gamepad_ = PAD_NOW; return edge; } auto Player::consumeAttackEdge() -> bool { // Mateix patró que `consumeJumpEdge` però per al botó d'atac. El botó // físic ve d'`GamepadBindings`, persistit per dispositiu — l'usuari // l'ha pogut remapejar des del menú de servei. const bool* keys = InputState::getKeyboardState(); const bool KEY_NOW = keys[controls_.attack]; bool edge = (KEY_NOW && !attack_held_keyboard_); attack_held_keyboard_ = KEY_NOW; SDL_Gamepad* pad = GamepadManager::get().getForPlayer(id_); const auto& slot = (id_ == 0) ? Options::controls().gamepad_p1 : Options::controls().gamepad_p2; const int ATK_BTN = GamepadBindings::get().getButton(slot.name, slot.path, GamepadBindings::Action::ATTACK); const bool PAD_NOW = (pad != nullptr) && !InputState::isBlocked() && GamepadBindings::isPressed(pad, ATK_BTN); if (PAD_NOW && !attack_held_gamepad_) { edge = true; } attack_held_gamepad_ = PAD_NOW; return edge; } auto Player::currentFacingCardinal() const -> Cardinal { switch (facing_) { case Facing::NORTH: return Cardinal::NORTH; case Facing::SOUTH: return Cardinal::SOUTH; case Facing::EAST: return Cardinal::EAST; case Facing::WEST: return Cardinal::WEST; } return Cardinal::EAST; } void Player::enterAttacking(Cardinal facing) { state_ = State::ATTACKING; attacking_remaining_ms_ = static_cast(whip_config.swing_ms + whip_config.cooldown_ms); // Garantim que facing/flip i cardinal queden coherents amb la // direcció triada (per si l'atac s'ha disparat amb facing residual, // p.ex. just després d'un canvi d'input). applyCardinal(facing); if (facing == Cardinal::NONE) { return; } // So del swing — disparat ací (no des del WhipAttack) perquè és el // jugador qui executa l'acció; si demà el spawn falla per qualsevol // motiu, el so del gest segueix sonant. if (!whip_config.sound_key.empty() && Audio::get() != nullptr) { Audio::get()->playSound(whip_config.sound_key, Audio::Group::GAME); } // Mapa de Cardinal → int al payload (vegeu `WhipSpawnPayload`). int dir_int = 0; switch (facing) { case Cardinal::NORTH: dir_int = 1; break; case Cardinal::SOUTH: dir_int = 2; break; case Cardinal::EAST: dir_int = 3; break; case Cardinal::WEST: dir_int = 4; break; case Cardinal::NONE: return; } // Passem el pixel_rect (16×32 a sam/morcus) al payload; el WhipAttack // en deriva tant la draw_rect (sprite del látigo, més gran) com la // damage_rect (al nivell dels peus, retallada per bloquejadors). if (bus_ != nullptr) { const float RANGE_PX = whip_config.range_tiles * static_cast(Map::tile_size); bus_->publish("whip_spawn", WhipSpawnPayload{ .owner = this, .direction = dir_int, .owner_box = getPosition(), .range_px = RANGE_PX, }); } } 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_south). 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; if (!hurt_config.sound_key.empty() && Audio::get() != nullptr) { Audio::get()->playSound(hurt_config.sound_key, Audio::Group::GAME); } 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; // Caiguda per agujero: el sòl ja no és present sota el jugador, així // que NO clampem `jump_z_` a 0. Deixem baixar a negatius (visualment // el sprite "s'enfonsa" sota el sòl) i, en superar `HOLE_FALL_DEPTH`, // disparem la mort. La cinemàtica de respawn (`enterDying → // spawnFromAir`) reseteja el flag i la posició al spawn point. if (falling_through_hole_) { if (jump_z_ <= -HOLE_FALL_DEPTH) { enterDying(DeathCause::HOLE); } return false; } if (jump_z_ <= 0.0F) { // Aterratge sobre decal-trigger (Hole, futurs pinchos plans...): // saltem el LANDING_STUN i transicionem directament a la caiguda // pel forat. Sense aquesta detecció, l'aterratge entraria a // LANDING_STUN (~200 ms flotant sobre el forat) i només quan // tornés a GROUNDED `Hole::onPlayerContact` ho dispararia. La // hit-box (8×8 centrada al sprite) és la mateixa que `Hole` // usa al seu re-check; coherent. Si el manager no s'ha // injectat (tests stand-alone), el camí d'aterratge segueix sent // el habitual. if (entity_manager_ != nullptr) { const Entity* trigger = entity_manager_->findFloorTriggerAt(getHitBox()); if (trigger != nullptr) { const auto BOX = trigger->getCollisionRect(); const float CENTER_X = BOX.x + (BOX.w * 0.5F); const float CENTER_Y = BOX.y + (BOX.h * 0.5F); const float FLOOR_Y = BOX.y + BOX.h; enterHoleFall(CENTER_X, CENTER_Y, FLOOR_Y); // No retornem `true`: no és un aterratge "normal" i // `applyMovement` no ha de cridar `resolveLandingOverlap`. return false; } } 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. // També DYING — un jugador en cicle de mort no rep cap més // efecte fins que respawneja. if (other->isOutOfPlay() || other->isDying()) { 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::DYING) { applyDyingMovement(delta_time); return; } if (state_ == State::HURT) { applyHurtMovement(delta_time); return; } if (state_ == State::LANDING_STUN) { applyLandingStunMovement(delta_time); return; } if (state_ == State::ATTACKING) { // Cap moviment durant ATTACKING. Animem (perquè avance la // seqüència `attack_*`) i actualitzem la clau d'animació segons // la sub-fase (swing vs cooldown). animate(delta_time); setCurrentAnimation(animationName(false)); 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::enterDying(DeathCause cause) { // Idempotent. Un segon contacte de momia mentre el jugador ja està // en DYING no reinicia l'animació ni el comptador. Tampoc disparem // el cicle si el jugador està fora de joc (EXITED al passar la // porta, DISABLED durant les transicions de sala). if (state_ == State::DYING || isOutOfPlay()) { return; } state_ = State::DYING; intended_vx_ = 0.0F; intended_vy_ = 0.0F; input_vx_ = 0.0F; input_vy_ = 0.0F; motion_time_ms_ = 0.0F; being_pushed_ = false; jump_z_ = 0.0F; jump_vz_ = 0.0F; falling_through_hole_ = false; hole_clip_bottom_y_ = 0.0F; landing_stun_remaining_ms_ = 0.0F; attacking_remaining_ms_ = 0.0F; turning_remaining_ms_ = 0.0F; pending_cardinal_ = Cardinal::NONE; intermediate_cardinal_ = Cardinal::NONE; last_input_cardinal_ = Cardinal::NONE; setVelX(0.0F); setVelY(0.0F); death_animation_complete_ = false; respawn_remaining_ms_ = 0.0F; // Presentació segons la causa. Cada `DeathCause` té la seua animació // dedicada al YAML del player; `applyDyingMovement` arma el respawn // timer quan `animationIsCompleted()` flanqueja, igual per a totes. switch (cause) { case DeathCause::HOLE: setCurrentAnimation("death_hole"); break; case DeathCause::GENERIC: default: setCurrentAnimation("death_spinning"); break; } // So del cop letal: reaprofitem `hurt.wav` (ja precarregat per // `readHurtBlock`). El bloc `death:` no té camp de so propi — // semànticament l'impacte mortal és la versió forta de `hurt`. if (!hurt_config.sound_key.empty() && Audio::get() != nullptr) { Audio::get()->playSound(hurt_config.sound_key, Audio::Group::GAME); } } void Player::applyDyingMovement(float delta_time) { // Avancem l'animació; el sprite no es mou (la posició queda // congelada al punt de la mort). `animationIsCompleted()` flanqueja // un cop la roda arriba al darrer frame (loop:false). Llavors // descomptem `respawn_delay_ms` mantenint el sprite al darrer frame // i, en arribar a 0, disparem el respawn. animate(delta_time); if (!death_animation_complete_ && animationIsCompleted()) { death_animation_complete_ = true; respawn_remaining_ms_ = static_cast(death_config.respawn_delay_ms); } if (death_animation_complete_) { respawn_remaining_ms_ -= delta_time * 1000.0F; if (respawn_remaining_ms_ <= 0.0F) { respawnFromDying(); } } } void Player::respawnFromDying() { // Vides: decreix sempre. Pot anar a negatius — no hi ha game over. --lives_remaining_; death_animation_complete_ = false; respawn_remaining_ms_ = 0.0F; // Cinemàtica idèntica a la d'INTRO de sala: el jugador cau des de // dalt fins al passadís d'spawn (`spawnFromAir` reseteja primer i // després posa AIRBORNE amb l'altura derivada de la geometria). // Així el respawn post-mort comparteix tota la sensació visual i // física del primer spawn — incloent el LANDING_STUN final. spawnFromAir(spawn_x_, spawn_y_); } 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; } // Limbo post-mort: durant `respawn_delay_ms` entre la fi de // `death_spinning` i el `spawnFromAir`, el jugador queda invisible. // Així el cicle complet és: animació rodant → desapareix → ressuscita // caient des de dalt. Sense aquest gat, el sprite quedaria pintat al // darrer frame de la roda mentre passa el delay. if (state_ == State::DYING && death_animation_complete_) { return; } // Mentre el jugador està enlaire, el sprite es desplaça visualment // `jump_z_` píxels: cap amunt si és positiu (salt), cap avall si és // negatiu (caiguda per `Hole`, que deixa baixar `jump_z_` a negatius // fins a `-HOLE_FALL_DEPTH`). 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. El cas // `jump_z_ == 0` (a terra) surt per l'early-return per evitar tocar // `pos_.y` innecessàriament. // Caiguda per agujero: el sprite es "menja" pel sòl. Fixem un clip // global a la franja [0, hole_clip_bottom_y_] del canvas (el blitter // de `Surface` respecta `Rendering::activeClip()`); a mesura que // `pos_.y - jump_z_` baixa, els píxels del sprite que creuen la // línia deixen de pintar-se. Fem el push/pop al voltant de tot el // render del sprite per cobrir tant el cas `jump_z_==0` (caiguda // just iniciada) com els frames posteriors. `pushClip` ja clamp // a la mida del canvas, així que un `h` negatiu es traduiria en // una franja buida i el sprite quedaria invisible — comportament // correcte si el clip ja ha "tapat" tota la pantalla. const bool CLIP_HOLE = falling_through_hole_; if (CLIP_HOLE) { const auto CANVAS = Rendering::activeCanvas(); Rendering::pushClip({ .x = 0, .y = 0, .w = CANVAS.width, .h = static_cast(hole_clip_bottom_y_), }); } if (jump_z_ == 0.0F) { AnimatedSprite::render(); } else { const float SAVED_Y = pos_.y; pos_.y = SAVED_Y - jump_z_; AnimatedSprite::render(); pos_.y = SAVED_Y; } if (CLIP_HOLE) { Rendering::popClip(); } } 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; } // Caient per un agujero: el cos s'enfonsa sota el sòl, dibuixar una // sombra al ras del sòl trenca la il·lusió. Suprimim la sombra fins // que `enterDying` netegi el flag. if (falling_through_hole_) { 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); } // Sufix d'animació per cardinal — empat 1:1 amb l'enum `Facing` i el // sufix declarat als YAMLs de jugador (sam.yaml, morcus.yaml). auto Player::facingSuffix(Facing facing) -> const char* { switch (facing) { case Facing::NORTH: return "_north"; case Facing::SOUTH: return "_south"; case Facing::EAST: return "_east"; case Facing::WEST: return "_west"; } return "_east"; } // Prefix d'animació per estat. ATTACKING té dues sub-fases: durant // `swing_ms` és `attack_*` (animació del braç llançant el látigo); durant // `cooldown_ms` posterior, `recover_*`. A AIRBORNE/LANDING_STUN/HURT, la // variant és per direcció, no per velocitat: l'estat bloqueja el `moving` // argument i no fa servir walk/stand. auto Player::statePrefix(State state, bool moving, bool in_attack_swing) -> const char* { switch (state) { case State::AIRBORNE: return "jump"; case State::LANDING_STUN: return "stun"; case State::HURT: return "hurt"; case State::ATTACKING: return in_attack_swing ? "attack" : "recover"; case State::DYING: // L'animació concreta (`death_spinning`) la fixa `enterDying`; // `applyDyingMovement` no torna a cridar `setCurrentAnimation`, // així que aquest valor no s'usa mai. El retornem només per // exhaustivitat del switch. return "death"; case State::GROUNDED: case State::EXITED: case State::DISABLED: break; } return moving ? "walk" : "stand"; } auto Player::animationName(bool moving) const -> std::string { const bool IN_ATTACK_SWING = (attacking_remaining_ms_ > static_cast(whip_config.cooldown_ms)); return std::string(statePrefix(state_, moving, IN_ATTACK_SWING)) + facingSuffix(facing_); } 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; falling_through_hole_ = false; hole_clip_bottom_y_ = 0.0F; landing_stun_remaining_ms_ = 0.0F; attacking_remaining_ms_ = 0.0F; turning_remaining_ms_ = 0.0F; pending_cardinal_ = Cardinal::NONE; intermediate_cardinal_ = Cardinal::NONE; last_input_cardinal_ = Cardinal::NONE; // Flags del cicle DYING (que `respawnFromDying` també neteja, però // si algú crida `respawn` directament des d'un altre estat, han de // quedar coherents per al pròxim cicle de mort). death_animation_complete_ = false; respawn_remaining_ms_ = 0.0F; // Reseteig del facing al d'spawn original (mirant cap a la càmera): // sense això, un EXITED que entrava per la porta amb facing NORTH // re-aparix d'esquena al pròxim cicle. Mateixa lògica que el ctor. facing_ = Facing::SOUTH; setCurrentAnimation("stand_south"); 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::enterHoleFall(float center_x, float center_y, float floor_y) { // Cap guard de state ací: la responsabilitat la té el caller perquè // hi ha dos callers vàlids amb states distints: // - `Hole::onPlayerContact` (state GROUNDED, el jugador camina i // trepitja el tile del forat). // - `Player::integrateJump` (state AIRBORNE, en aterrar damunt // d'un decal-trigger en lloc d'entrar a LANDING_STUN). // Posar `if (state_ != GROUNDED) return` ací bloquejaria el segon // cas i obligaria a hacks tipus "forçar GROUNDED abans de cridar". // Snap del centre de la hit-box a la coordenada central del forat. // Resolem `pos = center - (hit_offset + hit_size/2)` per a cada eix: // així el centre exacte de la caja de contacte queda alineat amb // (center_x, center_y), independentment de la geometria interna del // sprite. `setPosX/Y` és l'API canònica per teleportar el jugador // (la usen `respawn` i `spawnFromAir`). const float HIT_HALF = static_cast(hit_size) * 0.5F; setPosX(center_x - static_cast(hit_x_offset) - HIT_HALF); setPosY(center_y - static_cast(hit_y_offset) - HIT_HALF); // Frenem qualsevol inèrcia prèvia: la caiguda ha de ser estàtica al // centre del forat. intended_vx_ = 0.0F; intended_vy_ = 0.0F; input_vx_ = 0.0F; input_vy_ = 0.0F; motion_time_ms_ = 0.0F; being_pushed_ = false; setVelX(0.0F); setVelY(0.0F); jump_z_ = 0.0F; jump_vz_ = 0.0F; state_ = State::AIRBORNE; falling_through_hole_ = true; // Línia visual on el clip rect ha de retallar el sprite. Restem // `SPRITE_FLOOR_OFFSET` perquè la base del sprite té píxels // transparents — el retall ha de coincidir amb la silueta visible, // no amb la vora del rect del sprite. hole_clip_bottom_y_ = floor_y - SPRITE_FLOOR_OFFSET; // Animació de caiguda: respectem el `facing_` que ja portava el jugador // (no té sentit reorientar-lo cap al sud només perquè caigui en un // forat). Com `state_` ja és AIRBORNE, `animationName(false)` resol el // prefix `jump_` i hi afegeix el sufix cardinal correcte — // `jump_north`, `jump_south`, `jump_east` o `jump_west` segons el // facing actual. Si demà cal una animació de "cara de pànic", només // cal canviar aquí pel `setCurrentAnimation` corresponent. setCurrentAnimation(animationName(false)); } 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 SOUTH ja resol a `jump_south`. `respawn` ja ha posat facing a // SOUTH; ho deixem explícit ací per claredat de la cinemàtica. facing_ = Facing::SOUTH; setCurrentAnimation("jump_south"); } 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); }