diff --git a/data/player/morcus.yaml b/data/player/morcus.yaml index 6911f45..5c16c0e 100644 --- a/data/player/morcus.yaml +++ b/data/player/morcus.yaml @@ -27,6 +27,9 @@ animations: stand_down: { frames: [0] } stand_up: { frames: [4] } stand: { frames: [8] } + jump_down: { frames: [0] } + jump_up: { frames: [4] } + jump: { frames: [8] } walk_down: frames: [0, 1, 2, 3] @@ -64,3 +67,18 @@ turn: east_to_west: up # passa per dalt (stand_up) north_to_south: east # passa per la dreta (stand, sense flip) south_to_north: west # passa per l'esquerra (stand, amb flip) + +# Salt vertical (px verticals "visuals", no afecta la posició al món). +# impulse → velocitat vertical inicial (px/s) en deixar el sòl. +# gravity → acceleració descendent constant (px/s²). +# landing_stun_ms → temps en què el jugador queda paralitzat en aterrar +# (no es pot moure ni tornar a saltar). +# height → alçada del cos del jugador en píxels. S'usa per al +# filtratge de col·lisions per altura: un jugador +# saltant per damunt d'aquest valor no col·lisiona. +# Amb impulse=240 i gravity=600: pic ≈ 48 px, temps total en l'aire ≈ 0.8 s. +jump: + impulse: 240 + gravity: 600 + landing_stun_ms: 200 + height: 24 diff --git a/data/player/sam.yaml b/data/player/sam.yaml index 3481f3e..12f2f13 100644 --- a/data/player/sam.yaml +++ b/data/player/sam.yaml @@ -27,6 +27,9 @@ animations: stand_down: { frames: [1] } stand_up: { frames: [5] } stand: { frames: [9] } + jump_down: { frames: [1] } + jump_up: { frames: [5] } + jump: { frames: [9] } walk_down: frames: [0, 1, 2, 3] @@ -64,3 +67,18 @@ turn: east_to_west: up # passa per dalt (stand_up) north_to_south: east # passa per la dreta (stand, sense flip) south_to_north: west # passa per l'esquerra (stand, amb flip) + +# Salt vertical (px verticals "visuals", no afecta la posició al món). +# impulse → velocitat vertical inicial (px/s) en deixar el sòl. +# gravity → acceleració descendent constant (px/s²). +# landing_stun_ms → temps en què el jugador queda paralitzat en aterrar +# (no es pot moure ni tornar a saltar). +# height → alçada del cos del jugador en píxels. S'usa per al +# filtratge de col·lisions per altura: un jugador +# saltant per damunt d'aquest valor no col·lisiona. +# Amb impulse=240 i gravity=600: pic ≈ 48 px, temps total en l'aire ≈ 0.8 s. +jump: + impulse: 240 + gravity: 600 + landing_stun_ms: 200 + height: 24 diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index 170311b..3f3d003 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -63,6 +63,7 @@ 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); @@ -121,6 +122,22 @@ void Player::loadFrom(const std::string& yaml_path) { 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::readJumpBlock(const fkyaml::node& yaml) { + jump_config = JumpConfig{}; + body_height = 0; + 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("height")) { body_height = j["height"].get_value(); } } void Player::applyCardinal(Cardinal c) { @@ -329,6 +346,42 @@ void Player::tick(float delta_time) { // (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 @@ -379,9 +432,96 @@ void Player::tick(float delta_time) { // 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::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 ja al final d'`integrateJump`. + integrateJump(delta_time); + + // 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. @@ -390,9 +530,12 @@ void Player::applyMovement(float delta_time) { // Snap al passadís: si només hi ha moviment horitzontal (o vertical) i // la caixa està prou prop d'una lane perpendicular (≤ mitja caixa), la - // Y (o X) es corregeix gradualment per enfilar el passadís. - const bool ONLY_H = (VX != 0.0F) && (VY == 0.0F); - const bool ONLY_V = (VY != 0.0F) && (VX == 0.0F); + // 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(); @@ -420,7 +563,8 @@ void Player::applyMovement(float delta_time) { 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. + // 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)); @@ -444,7 +588,38 @@ void Player::applyMovement(float delta_time) { } } +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; +} + 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"; diff --git a/source/game/entities/player.hpp b/source/game/entities/player.hpp index e4b95c9..ed71651 100644 --- a/source/game/entities/player.hpp +++ b/source/game/entities/player.hpp @@ -8,6 +8,7 @@ #include #include "core/rendering/sprite/animated_sprite.hpp" +#include "external/fkyaml_node.hpp" #include "game/map/map.hpp" class EventBus; @@ -22,6 +23,20 @@ class Player : public AnimatedSprite { SDL_Scancode down; SDL_Scancode left; SDL_Scancode right; + SDL_Scancode jump; + }; + + // Estat d'alt nivell del jugador. Ortogonal a `Facing`/`Cardinal` + // (direcció) i a `being_pushed_` (flag d'animació). Determina què pot + // fer el jugador aquest frame: GROUNDED accepta tot l'input habitual + // i el botó de salt; AIRBORNE ignora canvis de direcció (la velocitat + // i el facing queden fixats al moment del despegue, sols la física + // del salt mou jump_z_/jump_vz_); LANDING_STUN bloca tot l'input fins + // que el timer s'esgota. + enum class State : std::uint8_t { + GROUNDED, + AIRBORNE, + LANDING_STUN, }; // Geometria carregada des del YAML del player (p.ex. `data/player/sam.yaml`). @@ -51,6 +66,11 @@ class Player : public AnimatedSprite { inline static int hit_x_offset = 0; inline static int hit_y_offset = 0; + // Alçada del cos del jugador en píxels (camp `jump.height` del YAML). + // L'usen els filtres de col·lisió per altura: si un altre actor ens + // creua mentre `jump_z_` és superior a aquest valor, no col·lisionem. + inline static int body_height = 0; + // Ha de cridar-se abans d'instanciar Player; GameScene ho fa al ctor. static void loadFrom(const std::string& yaml_path); @@ -127,11 +147,31 @@ class Player : public AnimatedSprite { // Y de la base del jugador (cara inferior de la caixa de colisió). Usada // per al Z-order contra els sarcs: peus per sobre d'un sarc → darrere; - // per sota → davant. + // per sota → davant. Mentre el jugador està en l'aire, el feet visual + // puja amb `jump_z_` perquè s'ordene davant dels actors a la mateixa + // fila lògica. [[nodiscard]] auto getFeetY() const -> float { + return getPosY() + static_cast(collision_y_offset + collision_size) - jump_z_; + } + + // Y de la base del jugador IGNORANT el salt (sempre la base del cos + // al món, no la posició visual). L'usa el render de la sombra perquè + // es pinte sempre al sòl, encara que el jugador estigui enlaire. + [[nodiscard]] auto getGroundFeetY() const -> float { return getPosY() + static_cast(collision_y_offset + collision_size); } + // Estat actual de la màquina d'estats. L'usa el render (animació + // jump_*), el resolver de col·lisions (filtre per altura) i el + // GameScene (per saber si renderitzar la sombra). + [[nodiscard]] auto getState() const -> State { return state_; } + [[nodiscard]] auto isAirborne() const -> bool { return state_ == State::AIRBORNE; } + + // Alçada actual del salt (píxels visuals per damunt del sòl). + // 0 quan està a terra. L'usa el render i el sistema de col·lisions + // per altura. + [[nodiscard]] auto getJumpZ() const -> float { return jump_z_; } + // Injecta el bus del nivell. El Player publica `on_tile_entered` cada // vegada que el centre de la seua caixa de col·lisió entra en una // cel·la nova del tilemap, amb payload `PaintedTile{col,row,paint_tile_index_}`. @@ -166,6 +206,12 @@ class Player : public AnimatedSprite { // bloquejar el moviment). [[nodiscard]] auto collisionBox() const -> SDL_FRect override { return getBaseBox(); } + // Render del jugador. Mentre està en l'aire, el sprite es desplaça + // visualment cap amunt `jump_z_` píxels (el cos del jugador, no la + // sombra al sòl, que la pinta el GameScene a banda). La col·lisió i + // la posició al món no s'alteren. + void render() override; + private: // Alias privat històric. Mantingut perquè viu dins de l'implementació // (snapToLane, clamp). Els consumidors externs usen `getBaseBox()`. @@ -308,6 +354,53 @@ class Player : public AnimatedSprite { // per defecte de TurnConfig encara no són visibles per al compilador. static TurnConfig turn_config; + // Configuració del salt llegida del bloc `jump:` del YAML del player. + // Si `impulse_px_per_s == 0` (o el bloc no existeix) la mecànica del + // salt queda desactivada — el botó no té efecte. + struct JumpConfig { + float impulse_px_per_s{0.0F}; // Velocitat vertical inicial. + float gravity_px_per_s2{0.0F}; // Acceleració descendent constant. + int landing_stun_ms{0}; // Stun en aterrar (no es mou ni salta). + }; + static JumpConfig jump_config; + + // Llig el bloc `jump:` del YAML i n'omple `jump_config` + `body_height`. + // Membre estàtic perquè accedeixi als estàtics privats sense exposar-los. + static void readJumpBlock(const fkyaml::node& yaml); + + // Estat de la màquina d'estats. Mutat per `tick`/`applyMovement`. Per + // defecte GROUNDED al spawn. + State state_{State::GROUNDED}; + + // Física del salt (només té sentit en AIRBORNE; ambdós a 0 a terra). + // jump_z_ → altura visual del jugador per damunt del sòl, en píxels. + // jump_vz_ → velocitat vertical actual (positiva = puja, negativa = cau). + float jump_z_{0.0F}; + float jump_vz_{0.0F}; + + // Temps restant de stun d'aterratge (ms). Mentre > 0, l'estat és + // LANDING_STUN i tot l'input queda bloquejat. + float landing_stun_remaining_ms_{0.0F}; + + // Estat anterior del botó de salt per fer detecció de flanc (saltem + // només a la transició no-premut → premut, no mentre es manté). Es + // manté un per teclat i un per mando perquè poden conviure. + bool jump_held_keyboard_{false}; + bool jump_held_gamepad_{false}; + + // Llig el botó de salt aquest frame (teclat + mando assignat) i, + // amb edge-detection, retorna true si s'ha de despegar el salt. + [[nodiscard]] auto consumeJumpEdge() -> bool; + + // Inicia un salt: copia `intended_v*` com a velocitat fixada del salt, + // posa `jump_vz_ = impulse`, transiciona a AIRBORNE. + void startJump(); + + // Integra la física del salt aquest frame i, si toca el sòl, marca el + // canvi a LANDING_STUN. Retorna true si aquest frame ha aterrat (perquè + // `applyMovement` puga disparar la resolució del solapament PvP). + auto integrateJump(float delta_time) -> bool; + // Estat del gir en curs. Mentre `turning_remaining_ms_ > 0`, el facing/ // flip es manté forçat a `intermediate_cardinal_` i l'animació es força // a `stand` encara que el moviment continuï. En esgotar-se el timer, diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index 9f8362e..475facb 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -239,12 +239,14 @@ GameScene::GameScene(Config config) .down = Defaults::KeysGame::DOWN, .left = Defaults::KeysGame::LEFT, .right = Defaults::KeysGame::RIGHT, + .jump = Defaults::KeysGame::JUMP, }; const Player::Controls CONTROLS_P2{ .up = Defaults::KeysGameP2::UP, .down = Defaults::KeysGameP2::DOWN, .left = Defaults::KeysGameP2::LEFT, .right = Defaults::KeysGameP2::RIGHT, + .jump = Defaults::KeysGameP2::JUMP, }; // Spawn centrat al passadís exterior superior: la caixa de col·lisió // queda dins del passadís vertical del mig (entre columnes 1 i 2) i diff --git a/source/utils/defaults.hpp b/source/utils/defaults.hpp index dbef97f..bedd74f 100644 --- a/source/utils/defaults.hpp +++ b/source/utils/defaults.hpp @@ -77,6 +77,7 @@ namespace Defaults { constexpr SDL_Scancode DOWN = SDL_SCANCODE_DOWN; constexpr SDL_Scancode LEFT = SDL_SCANCODE_LEFT; constexpr SDL_Scancode RIGHT = SDL_SCANCODE_RIGHT; + constexpr SDL_Scancode JUMP = SDL_SCANCODE_M; constexpr SDL_Scancode EXIT = SDL_SCANCODE_ESCAPE; } // namespace KeysGame @@ -89,6 +90,7 @@ namespace Defaults { constexpr SDL_Scancode DOWN = SDL_SCANCODE_S; constexpr SDL_Scancode LEFT = SDL_SCANCODE_A; constexpr SDL_Scancode RIGHT = SDL_SCANCODE_D; + constexpr SDL_Scancode JUMP = SDL_SCANCODE_V; } // namespace KeysGameP2 namespace Player {