From 7b09a076b2f8b7cf580eb9e69eaada4c51f502b0 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sat, 25 Apr 2026 11:42:07 +0200 Subject: [PATCH 01/19] =?UTF-8?q?feat(player):=20salt=20vertical=20amb=20f?= =?UTF-8?q?=C3=ADsica,=20m=C3=A0quina=20d'estats=20i=20animaci=C3=B3=20jum?= =?UTF-8?q?p=5F*?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/player/morcus.yaml | 18 +++ data/player/sam.yaml | 18 +++ source/game/entities/player.cpp | 183 +++++++++++++++++++++++++++++- source/game/entities/player.hpp | 95 +++++++++++++++- source/game/scenes/game_scene.cpp | 2 + source/utils/defaults.hpp | 2 + 6 files changed, 313 insertions(+), 5 deletions(-) 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 { From cd50626b88d7fc01dd358cfef250c790fe967f95 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sat, 25 Apr 2026 11:49:48 +0200 Subject: [PATCH 02/19] =?UTF-8?q?feat(collisions):=20filtre=20per=20altura?= =?UTF-8?q?=20=E2=80=94=20saltar=20esquiva=20momies,=20roques=20i=20altres?= =?UTF-8?q?=20jugadors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/entities/door/door.yaml | 4 ++++ data/entities/floor_trap/floor_trap.yaml | 5 +++++ data/entities/mummy/mummy.yaml | 5 +++++ data/entities/rolling_rock/rolling_rock.yaml | 5 +++++ data/entities/sarcophagus/sarcophagus.yaml | 7 +++++++ data/entities/torch/torch.yaml | 4 ++++ data/entities/wall_trap/wall_trap.yaml | 5 +++++ source/game/entities/entity.hpp | 12 +++++++++++ .../entities/entity_manager_loadlevel.cpp | 20 +++++++++++++++++++ source/game/entities/player.cpp | 11 +++++++++- source/game/scenes/game_scene.cpp | 11 ++++++++++ 11 files changed, 88 insertions(+), 1 deletion(-) diff --git a/data/entities/door/door.yaml b/data/entities/door/door.yaml index bf86231..fabd3d8 100644 --- a/data/entities/door/door.yaml +++ b/data/entities/door/door.yaml @@ -4,6 +4,10 @@ sprite: path: door.gif size: [48, 32] tile_size: [3, 2] +collision: + # Porta = mur infranquejable. Igual que el sarc, alçada efectiva + # infinita per impedir que un jugador la salte per damunt. + height: 999 flags: collides: true interactable: true diff --git a/data/entities/floor_trap/floor_trap.yaml b/data/entities/floor_trap/floor_trap.yaml index e7b2aa1..d99c006 100644 --- a/data/entities/floor_trap/floor_trap.yaml +++ b/data/entities/floor_trap/floor_trap.yaml @@ -4,6 +4,11 @@ sprite: path: floor_trap.gif size: [16, 16] tile_size: [1, 1] +collision: + # Trampa al sòl: alçada 0 → qualsevol salt l'esquiva. (Avui amb + # `collides: false` la trampa no entra al filtre de contacte, però + # declarem el camp per quan es cablege la detecció via àrea.) + height: 0 flags: collides: false interactable: false diff --git a/data/entities/mummy/mummy.yaml b/data/entities/mummy/mummy.yaml index f271a60..41f6bf3 100644 --- a/data/entities/mummy/mummy.yaml +++ b/data/entities/mummy/mummy.yaml @@ -14,6 +14,11 @@ anchor: feet collision: size: [16, 16] base_size: [16, 16] + # Alçada del cos en píxels per al filtre de col·lisió per salt: un + # jugador amb jump_z > height no col·lisiona amb la momia (la salta + # per damunt). Amb impulse=240 + gravity=600 el pic ronda els 48 px, + # així que 24 deixa marge per esquivar amb un salt no perfecte. + height: 24 flags: collides: true interactable: false diff --git a/data/entities/rolling_rock/rolling_rock.yaml b/data/entities/rolling_rock/rolling_rock.yaml index 326f5b5..95f3745 100644 --- a/data/entities/rolling_rock/rolling_rock.yaml +++ b/data/entities/rolling_rock/rolling_rock.yaml @@ -4,6 +4,11 @@ sprite: path: rolling_rock.gif size: [16, 16] tile_size: [1, 1] +collision: + # `size`/`base_size` no calen per a la roca (tot el sprite és cos), + # però sí declarem `height` per al filtre de salt: 16 px → fàcil + # d'esquivar amb un salt curt. + height: 16 flags: collides: true interactable: false diff --git a/data/entities/sarcophagus/sarcophagus.yaml b/data/entities/sarcophagus/sarcophagus.yaml index 7193f62..ab2e2ff 100644 --- a/data/entities/sarcophagus/sarcophagus.yaml +++ b/data/entities/sarcophagus/sarcophagus.yaml @@ -7,6 +7,13 @@ sprite: # són "alçada" en falsa perspectiva, sense col·lisió. Perímetre ortogonal # de 8 tiles (3 N + 3 S + 1 O + 1 E). tile_size: [3, 1] +collision: + # Sarcòfag = obstacle massís: alçada efectiva infinita (999) per al + # filtre del salt. Un jugador no el pot saltar per damunt encara que + # el salt arribe al punt àlgid. La caixa de col·lisió real (logical + # 48×16) la calcula `placeSarcophagus` a partir de la geometria del + # mapa, no des d'aquest YAML. + height: 999 flags: collides: true interactable: true diff --git a/data/entities/torch/torch.yaml b/data/entities/torch/torch.yaml index 6b00340..b659ead 100644 --- a/data/entities/torch/torch.yaml +++ b/data/entities/torch/torch.yaml @@ -4,6 +4,10 @@ sprite: path: torch.gif size: [16, 16] tile_size: [1, 1] +collision: + # Decoratiu, no col·lisiona. Camp `height` declarat per coherència + # amb la resta d'entitats; no té efecte mentre `collides: false`. + height: 0 flags: collides: false interactable: false diff --git a/data/entities/wall_trap/wall_trap.yaml b/data/entities/wall_trap/wall_trap.yaml index 2ef051b..39461c1 100644 --- a/data/entities/wall_trap/wall_trap.yaml +++ b/data/entities/wall_trap/wall_trap.yaml @@ -4,6 +4,11 @@ sprite: path: wall_trap.gif size: [16, 16] tile_size: [1, 1] +collision: + # Trampa de paret: ix d'una vora alta i no es pot saltar per damunt. + # 999 = infranquejable. Igual que floor_trap, el filtre només té efecte + # quan la detecció de contacte hi entre. + height: 999 flags: collides: false interactable: false diff --git a/source/game/entities/entity.hpp b/source/game/entities/entity.hpp index 66ebf8f..ca7a1c5 100644 --- a/source/game/entities/entity.hpp +++ b/source/game/entities/entity.hpp @@ -113,6 +113,17 @@ class Entity { // Entitats sense animació retornen `nullopt` → frame 0 per defecte. [[nodiscard]] auto getAnimation() const -> const std::optional& { return anim_; } + // Alçada del cos de l'entitat en píxels. La consulten els filtres + // de col·lisió per altura: si un jugador en l'aire passa per damunt + // d'aquest valor (jump_z_ > height), no col·lisiona amb l'entitat — + // és la mecànica del salt per esquivar momies, roques, etc. Valor + // 0 = sense altura (no es pot esquivar saltant; per a entitats amb + // collides=false el camp és irrellevant). Per a obstacles que + // representen "murs" (sarcs, portes), el YAML els pot donar un + // valor molt alt (p.ex. 999) per fer-los infranquejables. + [[nodiscard]] auto getHeight() const -> int { return height_; } + void setHeight(int h) { height_ = h; } + // Caixa de col·lisió: per defecte és el `pixel_rect_` sencer (entitats // AABB-aligned). Si s'ha configurat un `CollisionBox`, retorna el // sub-rect dins del sprite — vida útil: validar col·lisions amb el @@ -150,6 +161,7 @@ class Entity { std::shared_ptr surface_{nullptr}; bool collides_{false}; bool interactable_{false}; + int height_{0}; SDL_FlipMode flip_{SDL_FLIP_NONE}; EventBus* bus_{nullptr}; }; diff --git a/source/game/entities/entity_manager_loadlevel.cpp b/source/game/entities/entity_manager_loadlevel.cpp index 4d26778..c4b1cc7 100644 --- a/source/game/entities/entity_manager_loadlevel.cpp +++ b/source/game/entities/entity_manager_loadlevel.cpp @@ -63,8 +63,15 @@ void EntityManager::checkPlayerCollisions(const std::vector& players) { for (const auto& entity : entities_) { if (!entity->collides()) { continue; } const auto BOX = entity->getCollisionRect(); + const int E_HEIGHT = entity->getHeight(); for (auto* p : players) { if (p == nullptr) { continue; } + // Filtre per altura: si el jugador està en l'aire i està per + // damunt de l'alçada del cos de l'entitat, no hi ha contacte. + // És el mecanisme del salt per esquivar momies, roques, etc. + // Quan height és 0 (entitat sense altura declarada), el filtre + // no es dispara mai i la col·lisió funciona com abans. + if (p->isAirborne() && E_HEIGHT > 0 && p->getJumpZ() > static_cast(E_HEIGHT)) { continue; } // Contacte actor-actor amb hit-box petita: la caixa base // 16×16 servia per moure (xoc amb murs), però per a l'event // "toca una entitat" volem precisió — que la momia mate quan @@ -109,6 +116,18 @@ namespace { return info; } + // Llig `collision.height` (alçada del cos en píxels) de la plantilla. + // 0 si no està declarada — ho usen els filtres de col·lisió per altura + // del salt: un jugador per damunt d'aquest valor no col·lisiona amb + // l'entitat. Per a obstacles "tipus mur" (sarc, porta) el YAML els + // dóna un valor molt alt per fer-los infranquejables. + auto readHeight(const fkyaml::node& tmpl) -> int { + if (!tmpl.contains("collision")) { return 0; } + const auto& c = tmpl["collision"]; + if (!c.contains("height")) { return 0; } + return c["height"].get_value(); + } + // Bloc `collision: { size: [w, h], base_size: [bw, bh]?, offset: [x, y]? }` // de la plantilla d'entitat. // - Sense `base_size` ni `offset`: la caja queda alineada a la vora @@ -212,6 +231,7 @@ namespace { if (const auto BOX = readCollisionBox(tmpl, sprite_out.width, sprite_out.height); BOX.has_value()) { entity->setCollisionBox(*BOX); } + entity->setHeight(readHeight(tmpl)); anchor_out = readAnchor(tmpl); return entity; } diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index 3f3d003..d59b377 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -300,9 +300,18 @@ auto Player::collidesWithWorld(const SDL_FRect& box) const -> bool { // 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); - return std::ranges::any_of(*all_players_, [this, &PROPOSED_HIT](const Player* p) { + 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); }); diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index 475facb..23fdbca 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -103,6 +103,17 @@ namespace { auto* b = ps[1]; if (a == nullptr || b == nullptr) { return; } + // Filtre per altura: si un dels dos està en l'aire per damunt + // del cos de l'altre, no hi ha contacte possible — el saltador + // sobrevola. Saltar fa de "moviment lliure" per al resolver. + // `body_height` és estàtic carregat des del YAML; cppcheck el llig + // com a sempre-zero perquè no veu la inicialització en runtime. + const auto BODY_H = static_cast(Player::body_height); + // cppcheck-suppress knownConditionTrueFalse + if (BODY_H > 0.0F && a->isAirborne() && a->getJumpZ() > BODY_H) { return; } + // cppcheck-suppress knownConditionTrueFalse + if (BODY_H > 0.0F && b->isAirborne() && b->getJumpZ() > BODY_H) { return; } + // Calculem l'overlap previst en l'espai de la hit-box petita: // els jugadors han de poder acostar-se molt (caixes de moviment // 16×16 quasi tocant) sense disparar el resolver; només quan el From a64f33fe4309e19566c92249a824f40ada6e53bd Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sat, 25 Apr 2026 11:52:25 +0200 Subject: [PATCH 03/19] =?UTF-8?q?feat(player):=20no=20pinta=20el=20s=C3=B2?= =?UTF-8?q?l=20mentre=20est=C3=A0=20en=20l'aire;=20aterratge=20s=C3=AD=20p?= =?UTF-8?q?inta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- source/game/entities/player.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index d59b377..9ec9243 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -583,7 +583,13 @@ void Player::applyMovement(float delta_time) { // 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. - if (bus_ != nullptr) { + // + // 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; From c4caa9021ed97b004c884c719578039b0a099201 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sat, 25 Apr 2026 11:55:04 +0200 Subject: [PATCH 04/19] feat(player): resol el solapament en aterrar damunt d'un altre jugador --- source/game/entities/player.cpp | 73 ++++++++++++++++++++++++++++++++- source/game/entities/player.hpp | 11 +++++ 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index 9ec9243..1cccecd 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -502,12 +502,81 @@ auto Player::integrateJump(float delta_time) -> bool { 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 ja al final d'`integrateJump`. - integrateJump(delta_time); + // 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 diff --git a/source/game/entities/player.hpp b/source/game/entities/player.hpp index ed71651..b7a7386 100644 --- a/source/game/entities/player.hpp +++ b/source/game/entities/player.hpp @@ -401,6 +401,17 @@ class Player : public AnimatedSprite { // `applyMovement` puga disparar la resolució del solapament PvP). auto integrateJump(float delta_time) -> bool; + // En aterrar, si la hit-box del jugador solapa amb la d'un altre, + // separa els dos sense ficar-los dins d'un mur. Algoritme: + // 1. Calcula la direcció centre→centre i la magnitud de + // solapament (MTV axis-aligned). + // 2. Empenta l'altre cap a fora; si no cap (mur al darrere), + // reverteix i empenta el saltador cap al sentit oposat. + // 3. Si tampoc cap, es deixa l'estat com està (overlap residual); + // a la pràctica aquest cas és molt rar i el usuari ja ha + // acceptat que no és crític. + void resolveLandingOverlap(); + // 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, From 64390f8ad769cd22c8d582adeba0f0fd7a6d20ce Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sat, 25 Apr 2026 11:59:13 +0200 Subject: [PATCH 05/19] =?UTF-8?q?feat(player):=20sombra=20transl=C3=BAcida?= =?UTF-8?q?=20sota=20el=20jugador=20mentre=20est=C3=A0=20en=20l'aire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- source/game/entities/player.cpp | 34 +++++++++++++++++++++++++++++++ source/game/entities/player.hpp | 6 ++++++ source/game/scenes/game_scene.cpp | 6 ++++++ 3 files changed, 46 insertions(+) diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index 1cccecd..71099a5 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -9,6 +9,7 @@ #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" @@ -689,6 +690,39 @@ void Player::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 diff --git a/source/game/entities/player.hpp b/source/game/entities/player.hpp index b7a7386..5ef685f 100644 --- a/source/game/entities/player.hpp +++ b/source/game/entities/player.hpp @@ -212,6 +212,12 @@ class Player : public AnimatedSprite { // la posició al món no s'alteren. void render() override; + // Pinta una sombra translúcida sota el jugador a nivell de sòl + // (sense aplicar `jump_z_`). El GameScene la crida en una passada + // pre-actors, així queda per sota dels actors i del propi jugador. + // No-op si el jugador no està en l'aire. + void renderShadow() const; + private: // Alias privat històric. Mantingut perquè viu dins de l'implementació // (snapToLane, clamp). Els consumidors externs usen `getBaseBox()`. diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index 23fdbca..a4278c2 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -532,6 +532,12 @@ void GameScene::render() { // explícita: l'editor edita el tilemap, no la capa de pintat). if (!SKIP_ACTORS && paint_layer_) { paint_layer_->render(); } if (!SKIP_ACTORS) { + // Passada de sombres dels jugadors saltant. Va per davant del + // pintat (al damunt) però per darrere de TOTS els actors — + // entitats i jugadors, inclòs el saltador mateix— així la sombra + // queda al sòl sense tapar res. `renderShadow` és no-op si el + // jugador està a terra, així que no cal filtrar al callsite. + for (const auto& p : players_) { p->renderShadow(); } // Z-order interleaving entitats ↔ players per `getFeetY()` ascendent: // els actors amb els peus més amunt queden dibuixats primer (al fons), // els més avall al davant. Així un sarc situat per darrere d'un player From 4758d5d8a4a83eb6229fc011405dff7c78d44e1a Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sat, 25 Apr 2026 12:16:03 +0200 Subject: [PATCH 06/19] =?UTF-8?q?refactor(player):=20mou=20`height`=20de?= =?UTF-8?q?=20jump=20a=20collision=20per=20coher=C3=A8ncia=20amb=20les=20e?= =?UTF-8?q?ntitats?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/player/morcus.yaml | 11 ++++--- data/player/sam.yaml | 11 ++++--- source/game/entities/player.cpp | 55 +++++++++++++++++++-------------- source/game/entities/player.hpp | 12 +++++-- 4 files changed, 54 insertions(+), 35 deletions(-) diff --git a/data/player/morcus.yaml b/data/player/morcus.yaml index 5c16c0e..7ef9949 100644 --- a/data/player/morcus.yaml +++ b/data/player/morcus.yaml @@ -15,6 +15,11 @@ collision: # `size` es reserva per a la col·lisió amb murs/sarcòfags i per enfilar # els passadissos. Centrada en el mateix punt que la caixa base. hit_size: 8 + # Alçada del cos en píxels per al filtre de col·lisió per altura: un + # actor saltant amb jump_z > height passa per damunt sense col·lisió. + # Mateix camp i semàntica que als YAML d'entitats (mummy, rolling_rock, + # etc.) — així el filtre queda uniforme per a tots els colls·lisionables. + height: 24 # Animacions en mapa (clau = nom). Camps per animació: # frames → llista d'índexs dins de l'spritesheet. @@ -73,12 +78,10 @@ turn: # 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. +# L'alçada del cos viu al bloc `collision:` (`height`), igual que la resta +# d'entitats: és una propietat de col·lisió, no de la física del salt. # 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 12f2f13..6380486 100644 --- a/data/player/sam.yaml +++ b/data/player/sam.yaml @@ -15,6 +15,11 @@ collision: # `size` es reserva per a la col·lisió amb murs/sarcòfags i per enfilar # els passadissos. Centrada en el mateix punt que la caixa base. hit_size: 8 + # Alçada del cos en píxels per al filtre de col·lisió per altura: un + # actor saltant amb jump_z > height passa per damunt sense col·lisió. + # Mateix camp i semàntica que als YAML d'entitats (mummy, rolling_rock, + # etc.) — així el filtre queda uniforme per a tots els colls·lisionables. + height: 24 # Animacions en mapa (clau = nom). Camps per animació: # frames → llista d'índexs dins de l'spritesheet. @@ -73,12 +78,10 @@ turn: # 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. +# L'alçada del cos viu al bloc `collision:` (`height`), igual que la resta +# d'entitats: és una propietat de col·lisió, no de la física del salt. # 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 71099a5..b2265b6 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -77,28 +77,7 @@ void Player::loadFrom(const std::string& yaml_path) { readSpriteSize(yaml, yaml_path, sprite_w, sprite_h); - 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; + 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). @@ -130,15 +109,43 @@ void Player::loadFrom(const std::string& yaml_path) { 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{}; - 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) { diff --git a/source/game/entities/player.hpp b/source/game/entities/player.hpp index 5ef685f..6b1c17c 100644 --- a/source/game/entities/player.hpp +++ b/source/game/entities/player.hpp @@ -66,7 +66,8 @@ 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). + // Alçada del cos del jugador en píxels (camp `collision.height` del + // YAML, mateixa convenció que entitats com mummy o rolling_rock). // 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; @@ -370,8 +371,13 @@ class Player : public AnimatedSprite { }; 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. + // Llig el bloc `collision:` del YAML i n'omple les caixes (size/base/ + // hit) + `body_height`. Llança si el bloc no existeix. Membre estàtic + // per accedir als estàtics privats sense exposar-los. + static void readCollisionBlock(const fkyaml::node& yaml, const std::string& yaml_path); + + // Llig el bloc `jump:` del YAML i n'omple `jump_config`. Si el bloc + // manca, deixa la mecànica del salt desactivada. static void readJumpBlock(const fkyaml::node& yaml); // Estat de la màquina d'estats. Mutat per `tick`/`applyMovement`. Per From f0a0515f2882dea79f0cfc1d528f9f113575f100 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sat, 25 Apr 2026 12:19:51 +0200 Subject: [PATCH 07/19] fix(player): puja la sombra pel valor `jump.shadow_y_offset` del YAML --- data/player/morcus.yaml | 5 +++++ data/player/sam.yaml | 5 +++++ source/game/entities/player.cpp | 8 +++++++- source/game/entities/player.hpp | 1 + 4 files changed, 18 insertions(+), 1 deletion(-) diff --git a/data/player/morcus.yaml b/data/player/morcus.yaml index 7ef9949..cab1f57 100644 --- a/data/player/morcus.yaml +++ b/data/player/morcus.yaml @@ -78,6 +78,10 @@ turn: # 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). +# shadow_y_offset → desplaçament vertical de la sombra (px, positiu = +# puja). Compensa la franja transparent que els +# sprites del jugador tenen sota els peus reals +# perquè camine "pel mig del passadís" (3 px aquí). # L'alçada del cos viu al bloc `collision:` (`height`), igual que la resta # d'entitats: és una propietat de col·lisió, no de la física del salt. # Amb impulse=240 i gravity=600: pic ≈ 48 px, temps total en l'aire ≈ 0.8 s. @@ -85,3 +89,4 @@ jump: impulse: 240 gravity: 600 landing_stun_ms: 200 + shadow_y_offset: 3 diff --git a/data/player/sam.yaml b/data/player/sam.yaml index 6380486..bd0f1a5 100644 --- a/data/player/sam.yaml +++ b/data/player/sam.yaml @@ -78,6 +78,10 @@ turn: # 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). +# shadow_y_offset → desplaçament vertical de la sombra (px, positiu = +# puja). Compensa la franja transparent que els +# sprites del jugador tenen sota els peus reals +# perquè camine "pel mig del passadís" (3 px aquí). # L'alçada del cos viu al bloc `collision:` (`height`), igual que la resta # d'entitats: és una propietat de col·lisió, no de la física del salt. # Amb impulse=240 i gravity=600: pic ≈ 48 px, temps total en l'aire ≈ 0.8 s. @@ -85,3 +89,4 @@ jump: impulse: 240 gravity: 600 landing_stun_ms: 200 + shadow_y_offset: 3 diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index b2265b6..2cb5664 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -146,6 +146,7 @@ void Player::readJumpBlock(const fkyaml::node& yaml) { 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(); } } void Player::applyCardinal(Cardinal c) { @@ -721,7 +722,12 @@ void Player::renderShadow() const { 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; + // 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. diff --git a/source/game/entities/player.hpp b/source/game/entities/player.hpp index 6b1c17c..5ac11a4 100644 --- a/source/game/entities/player.hpp +++ b/source/game/entities/player.hpp @@ -368,6 +368,7 @@ class Player : public AnimatedSprite { 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). + int shadow_y_offset{0}; // Px que puja la sombra (compensa transparències al peu del sprite). }; static JumpConfig jump_config; From 06ff11d563bd3ce24384648dbed9282033ae6456 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sat, 25 Apr 2026 12:21:27 +0200 Subject: [PATCH 08/19] =?UTF-8?q?fix(player):=20el=20z-order=20del=20salta?= =?UTF-8?q?dor=20respecta=20la=20seua=20posici=C3=B3=20al=20s=C3=B2l?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- source/game/entities/player.hpp | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/source/game/entities/player.hpp b/source/game/entities/player.hpp index 5ac11a4..1c03318 100644 --- a/source/game/entities/player.hpp +++ b/source/game/entities/player.hpp @@ -148,20 +148,22 @@ 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. 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. + // per sota → davant. NO restem `jump_z_`: el saltador no canvia de + // tile lògica, només s'eleva visualment, i el sorting ha de respectar + // la posició al món (un sarc al nord segueix darrere encara que el + // sprite del jugador estigui amunt). Veure també `getGroundFeetY`, + // que coincideix amb aquest mètode i s'usa específicament per + // marcar la posició de la sombra a nivell de sòl. [[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); } + // Alies semàntic: els peus del jugador al sòl. Coincideix amb + // `getFeetY` i s'usa al callsite del render de la sombra per fer + // explícit que la sombra es pinta al pla del sòl, no a la posició + // visual del sprite. + [[nodiscard]] auto getGroundFeetY() const -> float { return getFeetY(); } + // 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). From 3a750822f6fc8306952950e4b083359e9172defd Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sat, 25 Apr 2026 12:23:21 +0200 Subject: [PATCH 09/19] =?UTF-8?q?fix(player):=20la=20resoluci=C3=B3=20del?= =?UTF-8?q?=20solapament=20en=20aterrar=20usa=20la=20base-box,=20no=20la?= =?UTF-8?q?=20hit-box?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- source/game/entities/player.cpp | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index 2cb5664..1e1136d 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -515,10 +515,16 @@ 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(); + // Usem la BASE-box (16×16, el cos sencer) per al test, no la + // hit-box (8×8 de contacte). Volem separar els cossos + // visualment: si fem servir només la hit-box, dos jugadors + // poden quedar amb les base-boxes solapades a mig sprite (un + // dins de l'altre) tot i tenir les hit-boxes ja apartades. La + // base-box dóna la separació real del cos. + const SDL_FRect MY_BOX = getBaseBox(); + const SDL_FRect OT_BOX = other->getBaseBox(); SDL_FRect inter; - if (!SDL_GetRectIntersectionFloat(&MY_HIT, &OT_HIT, &inter)) { continue; } + if (!SDL_GetRectIntersectionFloat(&MY_BOX, &OT_BOX, &inter)) { continue; } // Eix de separació: el que té els centres més separats. És // l'axis-aligned MTV "natural" — equivalent a empentar l'altre @@ -527,10 +533,10 @@ void Player::resolveLandingOverlap() { // 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 MCX = MY_BOX.x + (MY_BOX.w * 0.5F); + const float MCY = MY_BOX.y + (MY_BOX.h * 0.5F); + const float OCX = OT_BOX.x + (OT_BOX.w * 0.5F); + const float OCY = OT_BOX.y + (OT_BOX.h * 0.5F); const float DX = OCX - MCX; const float DY = OCY - MCY; float push_x = 0.0F; From f3f5fa5e923f9ac845a4a78b4212b3b4055f041a Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sat, 25 Apr 2026 13:48:03 +0200 Subject: [PATCH 10/19] feat(player): animacions stun_* durant l'aterratge --- data/player/morcus.gif | Bin 1491 -> 2133 bytes data/player/morcus.yaml | 9 ++++++--- data/player/sam.gif | Bin 1516 -> 2142 bytes data/player/sam.yaml | 9 ++++++--- data/sounds/jump.wav | Bin 0 -> 8542 bytes data/sounds/land.wav | Bin 0 -> 1276 bytes source/game/entities/player.cpp | 14 ++++++++++++++ 7 files changed, 26 insertions(+), 6 deletions(-) create mode 100644 data/sounds/jump.wav create mode 100644 data/sounds/land.wav diff --git a/data/player/morcus.gif b/data/player/morcus.gif index d4caa57f1a7211044c5224d15ef30ca222df3577..97b32b8ef5ffd39f543d2ce9028e0dc5d8502ca2 100644 GIT binary patch delta 682 zcmV;b0#*If3)K(_M@dFFH(@{kppgky0idy90|f#9lVk;Ie^<%MR=V<)7eb}kG8iE$ zfMG^mvgDrX1_Ci85@{~dW#VYbfjYRsMobdh=5&chB>Z6xTSHQnbbz|seP9rP;MyV7 zv^lrcj&&G%#U{dbra8s#ciijcEo5;gGy%$U)eAwZm=UO{m1=v|^uRrw;SKZ6WinGd z-}eUBfqlLse;%8nh^9~=ss5QjA?u(gtL+EkFhYM}U!^ z%w(Y;dC399bpvO$&L9`nz)rH_EgW8jQR4d65_iIYQetRZa5)M@fNBaZ9>}AC;Np-x z6BinmAf-R?i%W0XIFIfiSU2@f3!l1_&rrrP^4ORae>b)f9opc7fHT^~e8vpbkgSdP zBB(rtrh=s%!8y<|P7BN^$A^yfks@;eTm_Lbz{3GceL zc!enaM5uQbuepaqC3ml=uuY)%f&7HVqFnF{9>Pz3A~0w^5=x8>p3k9A#mGV=x{C)^ z6oCb;;y)*Nm!tOd1|UV%Nb~Cx1^cBYDQyHxe-ow;9r(AG3d^ZY%Y~SlqDy%_m1{wP zD%7D~%cvcFa9(&qJEp?$p%j72S^k0<0#r4{IKXPTR_fHBauGBIsElXAx}~xiDToh7 zt6zV%Rw&}NHAsw}g1W|KdqixINJPdNVQ^UK;OGF0)mmx`o7VUU2o{_T0%q}s+A=%> Q476P|t)BPHfd~KqJB;N)Gynhq delta 35 qcmcaAaG9Ia-P6s&Jkfz6VIpT3W5UJ+W>&`B$tkQ^te?dg8LR=hhzXkj diff --git a/data/player/morcus.yaml b/data/player/morcus.yaml index cab1f57..7f8b148 100644 --- a/data/player/morcus.yaml +++ b/data/player/morcus.yaml @@ -32,9 +32,12 @@ animations: stand_down: { frames: [0] } stand_up: { frames: [4] } stand: { frames: [8] } - jump_down: { frames: [0] } - jump_up: { frames: [4] } - jump: { frames: [8] } + jump_down: { frames: [12] } + jump_up: { frames: [13] } + jump: { frames: [14] } + stun_down: { frames: [16] } + stun_up: { frames: [17] } + stun: { frames: [18] } walk_down: frames: [0, 1, 2, 3] diff --git a/data/player/sam.gif b/data/player/sam.gif index 98eaa5db4418fc30ca05abefc515d8109da65e06..db9f574b9b69bcd63df9c14c05433bd0d9b6b7ce 100644 GIT binary patch delta 666 zcmV;L0%iT|3*Hb3M@dFFH(@{kppgky0idy90|f#9lVk;gf6HF_@|VC2Cgjv;%NAk? z4PLNOj64X(5n!PTDDuJfw2(RvazGAgFx&2=Nu*jNZ<-(AgCBHhB`|eqaMfewH3Km` zMWD%aXfs~(NT3Ql;i*o5f~E_*s+iz=|JcjM*d?%_s_P}Mk;z%C*-(~9Wr$)CS7egKtBpBj5=EQ}Q&}() zwWLHR(HIz38U}!Hd23f&#h||e!vMzor6a`An9;bo_8O~SC$$V|=Q)S=@&T!U~QiW3Q zK+Uw>`rH;!ego=4>+6tdKqH3|4P#6y`oCt*G^6H-&P7y(DhAf~4EadNtxjap1U|SR z4S5LNT3XeYS|p|yo(M)zV#Tz4Yp3UcfeQUud_HFl^$taK@w$?FL%;@CEKdy!UfxL6 zV9iT-S+&bgyNZ#oW@`ZiBdb{jhSnjk^%!o2Vk$hghop|tu0Pe9Z@$9}!wT&Qe+BL} z8%t)V4c4!UrEFyp*M}fM(rd%*>2M@;pB#ze5i&S!<8J!|O}v)EwGFhOIS>H=JB$oD AX8-^I delta 36 rcmca7@P?by-P6s&Jkfz6VIpT3W5UJ+W>&_w$tkQ&Y~H6_85pbq$`uMf diff --git a/data/player/sam.yaml b/data/player/sam.yaml index bd0f1a5..f27a872 100644 --- a/data/player/sam.yaml +++ b/data/player/sam.yaml @@ -32,9 +32,12 @@ animations: stand_down: { frames: [1] } stand_up: { frames: [5] } stand: { frames: [9] } - jump_down: { frames: [1] } - jump_up: { frames: [5] } - jump: { frames: [9] } + jump_down: { frames: [12] } + jump_up: { frames: [13] } + jump: { frames: [14] } + stun_down: { frames: [16] } + stun_up: { frames: [17] } + stun: { frames: [18] } walk_down: frames: [0, 1, 2, 3] diff --git a/data/sounds/jump.wav b/data/sounds/jump.wav new file mode 100644 index 0000000000000000000000000000000000000000..920e27f53277c982af8e420341ef201f54830005 GIT binary patch literal 8542 zcmbuD$#&{S5Qe$CEb|D-E94DwPIAx5HjA4@FqqK{!XUs%3<3cHjASM=&j##W9x`eT zHbXb6Za$wY`{L&Bs=upB!hiqu{rit!U9NwA|M6!yk^SXom&^4{|N7%Um+|kL>nB&x zm-YRouTd9ohr>G~-K77{{qIe9D%0(rHGtdqzh(QIcX#K88^+u>r3bJ37VCp8)`K7N z0naTvbKTXB>A-K?IHE0?_C;2+S2^n$FU1i10W)s8lOy-cnHmK}Jx6Xamubhn5@XJh z@2{!b^`OLjjGnR-EPL57wB=5YvUzDtUAkIfEfj`U7^d|BM?c}{ciOT|7O#HGq#B@y z2C6~ojA$#uEC;|2zlb?aju_EbY93wB}l6hegz1KJ^W1%p(f2K16<=|V(-nly0+pFuZD z8cFo|(|K50V-k`!@YH)r^n?WgCP)z>C}p^ZphK~4#DIK3^$qSAP;jGk&;#l}h;z`; zAjgVagH#E+`#3$@K@Sw*KQt_^yLRd=;QR+1BhYt#;MDKHPI4B4MdbWE1v|M(J)jXagcJc;(7;!;UEEs!b$|=h53?m5Dp4}7*G~^uaIJJ4&l1M zyK`X)L-bjV6oB&tNgLyg^nSfnuRmB?-A;J_t{O$3p0A*XuitlA-+p~}$KbE#?r7cB z(0i%tjTkZ?MDd9`ZEd|7Kct75(7opliwBYJuXg*x345Eh6!qVGjb-M=drC~5Z+8S% zQY_%RCzI^VH`_NnV<8go`-p_=S-%h|As4c!Fh6OmXqpIdKa?9gN0UUd!o6j6Oxti9~|q^vea0lGcA6p7V-m34HRAM6T8C4~%8#GB`pjW>QHKLbKEBV`+*RK1F~p zw>v#7opLV2lMowPr;DRg6SJ8#fqX-K>EO`HLN=Shkkyfzs`i3GYZiqkwBUp%6S4lR zs@gHC$#YrE<*Y@G)~W3yno?d6>~+E^C8hO@rB=*~qOn#yr%-LQu+&S1 zJb{9&G_lmAVu3)hsx+|3QjtWtR#$M;$|VxzYORi=R*^_lsx>TfrA$JWs~BXtLPoye EeCE>W2)H>Sv>YqBt*905sj( zJ3JZW_H10BqIJ^z`Sa)D19S0#Id}n@3(>f<@PYo8$~>$fA3I2k3P%QzSV1NuK_I;q z7_T_MBnT*q1A=%gAOQmM&|N(f1x&50!UyWXDF+wPlmZ2*SU_4@DI{ItBC!WdegPDu zB%=bDT`; const char* { } return "jump"; } + // En LANDING_STUN, `stun_*` (per direcció): mostrem el frame + // d'aterratge fins que s'esgota el timer. La direcció ve fixada del + // moment del despegue (no es canvia mentre estem en l'aire). + if (state_ == State::LANDING_STUN) { + switch (facing_) { + case Facing::UP: + return "stun_up"; + case Facing::DOWN: + return "stun_down"; + case Facing::SIDE: + break; + } + return "stun"; + } switch (facing_) { case Facing::UP: return moving ? "walk_up" : "stand_up"; From 3646e6a4aaf76749af8a481aeb909e60726c00fd Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sat, 25 Apr 2026 13:50:30 +0200 Subject: [PATCH 11/19] feat(player): efectes de so configurables al despegue i a l'aterratge --- data/player/morcus.yaml | 5 +++++ data/player/sam.yaml | 5 +++++ source/game/entities/player.cpp | 18 ++++++++++++++++++ source/game/entities/player.hpp | 2 ++ 4 files changed, 30 insertions(+) diff --git a/data/player/morcus.yaml b/data/player/morcus.yaml index 7f8b148..1bcb827 100644 --- a/data/player/morcus.yaml +++ b/data/player/morcus.yaml @@ -85,6 +85,9 @@ turn: # puja). Compensa la franja transparent que els # sprites del jugador tenen sota els peus reals # perquè camine "pel mig del passadís" (3 px aquí). +# sound → fitxer .wav reproduït al despegue (relatiu a +# data/sounds/). Buit o absent = sense so. +# land_sound → fitxer .wav reproduït en aterrar. # L'alçada del cos viu al bloc `collision:` (`height`), igual que la resta # d'entitats: és una propietat de col·lisió, no de la física del salt. # Amb impulse=240 i gravity=600: pic ≈ 48 px, temps total en l'aire ≈ 0.8 s. @@ -93,3 +96,5 @@ jump: gravity: 600 landing_stun_ms: 200 shadow_y_offset: 3 + sound: jump.wav + land_sound: land.wav diff --git a/data/player/sam.yaml b/data/player/sam.yaml index f27a872..80bdcb6 100644 --- a/data/player/sam.yaml +++ b/data/player/sam.yaml @@ -85,6 +85,9 @@ turn: # puja). Compensa la franja transparent que els # sprites del jugador tenen sota els peus reals # perquè camine "pel mig del passadís" (3 px aquí). +# sound → fitxer .wav reproduït al despegue (relatiu a +# data/sounds/). Buit o absent = sense so. +# land_sound → fitxer .wav reproduït en aterrar. # L'alçada del cos viu al bloc `collision:` (`height`), igual que la resta # d'entitats: és una propietat de col·lisió, no de la física del salt. # Amb impulse=240 i gravity=600: pic ≈ 48 px, temps total en l'aire ≈ 0.8 s. @@ -93,3 +96,5 @@ jump: gravity: 600 landing_stun_ms: 200 shadow_y_offset: 3 + sound: jump.wav + land_sound: land.wav diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index 384f037..ac5b928 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -7,6 +7,7 @@ #include #include +#include "core/audio/audio.hpp" #include "core/input/gamepad_manager.hpp" #include "core/input/input_state.hpp" #include "core/rendering/screen.hpp" @@ -147,6 +148,17 @@ void Player::readJumpBlock(const fkyaml::node& yaml) { 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::applyCardinal(Cardinal c) { @@ -492,6 +504,9 @@ void Player::startJump() { 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); + } // 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. @@ -506,6 +521,9 @@ auto Player::integrateJump(float delta_time) -> bool { 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) { + Audio::get()->playSound(jump_config.land_sound_key, Audio::Group::GAME); + } return true; } return false; diff --git a/source/game/entities/player.hpp b/source/game/entities/player.hpp index 1c03318..d254ed1 100644 --- a/source/game/entities/player.hpp +++ b/source/game/entities/player.hpp @@ -371,6 +371,8 @@ class Player : public AnimatedSprite { float gravity_px_per_s2{0.0F}; // Acceleració descendent constant. int landing_stun_ms{0}; // Stun en aterrar (no es mou ni salta). int shadow_y_offset{0}; // Px que puja la sombra (compensa transparències al peu del sprite). + std::string sound_key; // Clau al Resource::Cache del so de despegue (buit = silenci). + std::string land_sound_key; // Clau del so d'aterratge (buit = silenci). }; static JumpConfig jump_config; From 188f035333af44d67cca8cf93d6227dd70ac637d Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sat, 25 Apr 2026 14:20:38 +0200 Subject: [PATCH 12/19] feat(player): saltar travessa el caminant; rebot mid-aire entre dos saltadors --- data/player/morcus.yaml | 17 +++++++---- data/player/sam.yaml | 17 +++++++---- source/game/entities/player.cpp | 28 ++++++++++++------ source/game/scenes/game_scene.cpp | 48 ++++++++++++++++++++++++------- 4 files changed, 78 insertions(+), 32 deletions(-) diff --git a/data/player/morcus.yaml b/data/player/morcus.yaml index 1bcb827..571c76a 100644 --- a/data/player/morcus.yaml +++ b/data/player/morcus.yaml @@ -15,11 +15,16 @@ collision: # `size` es reserva per a la col·lisió amb murs/sarcòfags i per enfilar # els passadissos. Centrada en el mateix punt que la caixa base. hit_size: 8 - # Alçada del cos en píxels per al filtre de col·lisió per altura: un - # actor saltant amb jump_z > height passa per damunt sense col·lisió. - # Mateix camp i semàntica que als YAML d'entitats (mummy, rolling_rock, - # etc.) — així el filtre queda uniforme per a tots els colls·lisionables. - height: 24 + # Alçada del cos en píxels. Funció DOBLE segons amb què col·lisione: + # · Contra entitats (mummy, rolling_rock, sarc): si `jump_z > height` + # d'aquella entitat, no hi ha col·lisió. Igual que als YAMLs + # d'entitats. + # · Contra altres jugadors: la col·lisió és binària per estat aeri. + # Si un està en l'aire i l'altre no → cap col·lisió. Si tots dos + # estan en l'aire, llavors `body_height` decideix si els rangs + # verticals solapen i s'aplica el rebot mid-aire. `0` = sense cos + # (ni rebot quan tots dos volen). + height: 12 # Animacions en mapa (clau = nom). Camps per animació: # frames → llista d'índexs dins de l'spritesheet. @@ -92,7 +97,7 @@ turn: # d'entitats: és una propietat de col·lisió, no de la física del salt. # Amb impulse=240 i gravity=600: pic ≈ 48 px, temps total en l'aire ≈ 0.8 s. jump: - impulse: 240 + impulse: 200 gravity: 600 landing_stun_ms: 200 shadow_y_offset: 3 diff --git a/data/player/sam.yaml b/data/player/sam.yaml index 80bdcb6..8599617 100644 --- a/data/player/sam.yaml +++ b/data/player/sam.yaml @@ -15,11 +15,16 @@ collision: # `size` es reserva per a la col·lisió amb murs/sarcòfags i per enfilar # els passadissos. Centrada en el mateix punt que la caixa base. hit_size: 8 - # Alçada del cos en píxels per al filtre de col·lisió per altura: un - # actor saltant amb jump_z > height passa per damunt sense col·lisió. - # Mateix camp i semàntica que als YAML d'entitats (mummy, rolling_rock, - # etc.) — així el filtre queda uniforme per a tots els colls·lisionables. - height: 24 + # Alçada del cos en píxels. Funció DOBLE segons amb què col·lisione: + # · Contra entitats (mummy, rolling_rock, sarc): si `jump_z > height` + # d'aquella entitat, no hi ha col·lisió. Igual que als YAMLs + # d'entitats. + # · Contra altres jugadors: la col·lisió és binària per estat aeri. + # Si un està en l'aire i l'altre no → cap col·lisió. Si tots dos + # estan en l'aire, llavors `body_height` decideix si els rangs + # verticals solapen i s'aplica el rebot mid-aire. `0` = sense cos + # (ni rebot quan tots dos volen). + height: 12 # Animacions en mapa (clau = nom). Camps per animació: # frames → llista d'índexs dins de l'spritesheet. @@ -92,7 +97,7 @@ turn: # d'entitats: és una propietat de col·lisió, no de la física del salt. # Amb impulse=240 i gravity=600: pic ≈ 48 px, temps total en l'aire ≈ 0.8 s. jump: - impulse: 240 + impulse: 200 gravity: 600 landing_stun_ms: 200 shadow_y_offset: 3 diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index ac5b928..3999d7a 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -322,17 +322,27 @@ auto Player::collidesWithWorld(const SDL_FRect& box) const -> bool { // (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. + // Regla de col·lisió jugador↔jugador (segons el feel triat): + // - Tots dos a terra → 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 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) { + const bool MY_AIR = (state_ == State::AIRBORNE); + const float MY_Z = jump_z_; + return std::ranges::any_of(*all_players_, [this, &PROPOSED_HIT, MY_AIR, MY_Z](const Player* p) { if (p == nullptr || p == this) { return false; } - if (AIRBORNE && body_height > 0 && Z > static_cast(body_height)) { 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); }); diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index a4278c2..579ce18 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -103,22 +103,22 @@ namespace { auto* b = ps[1]; if (a == nullptr || b == nullptr) { return; } - // Filtre per altura: si un dels dos està en l'aire per damunt - // del cos de l'altre, no hi ha contacte possible — el saltador - // sobrevola. Saltar fa de "moviment lliure" per al resolver. - // `body_height` és estàtic carregat des del YAML; cppcheck el llig - // com a sempre-zero perquè no veu la inicialització en runtime. - const auto BODY_H = static_cast(Player::body_height); - // cppcheck-suppress knownConditionTrueFalse - if (BODY_H > 0.0F && a->isAirborne() && a->getJumpZ() > BODY_H) { return; } - // cppcheck-suppress knownConditionTrueFalse - if (BODY_H > 0.0F && b->isAirborne() && b->getJumpZ() > BODY_H) { return; } + const bool A_AIR = a->isAirborne(); + const bool B_AIR = b->isAirborne(); + + // Aire ↔ tierra: el saltador es travessa net del caminant. Sense + // resolver, sense head-on, sense push. Els dos continuen amb la + // velocitat que portaven. Important: això NO és el filtre per + // altura del cos — és binari. Coincideix amb la regla d'`Player:: + // collidesWithWorld`: durant un salt no hi ha mai col·lisió amb + // un caminant, ni baix ni alt. + if (A_AIR != B_AIR) { return; } // Calculem l'overlap previst en l'espai de la hit-box petita: // els jugadors han de poder acostar-se molt (caixes de moviment // 16×16 quasi tocant) sense disparar el resolver; només quan el // contacte real (hit-box 8×8) s'activaria, neutralitzem o apliquem - // push. + // push (a terra) o invertim velocitats (en l'aire). const SDL_FRect BOX_A = a->getHitBox(); const SDL_FRect BOX_B = b->getHitBox(); const SDL_FRect NEXT_A{ @@ -135,6 +135,32 @@ namespace { }; if (!rectsOverlap(NEXT_A, NEXT_B)) { return; } + // Tots dos en l'aire (A_AIR == B_AIR == true aquí, ja descartat + // l'altre cas a dalt): rebot mid-aire si els rangs verticals + // [z, z+body_height] solapen. Si la diferència d'alçades és + // massa gran, ja no hi ha contacte real (un està molt més amunt + // que l'altre) i no fem res. Si solapen, invertim ambdues + // velocitats horitzontals i els deixem rebotar fins a aterrar + // — els dos cauen en stun, com a càstig per abusar del salt. + if (A_AIR) { + // cppcheck-suppress knownConditionTrueFalse + const auto BODY_H = static_cast(Player::body_height); + // cppcheck-suppress knownConditionTrueFalse + if (BODY_H <= 0.0F) { return; } + if (std::fabs(a->getJumpZ() - b->getJumpZ()) >= BODY_H) { return; } + // Saltadors estàtics horitzontalment (cas degenerat: tots dos + // jumpejant verticals al mateix x): no hi ha velocitat a + // invertir, deixem-los — el predictiu no tornarà a disparar + // perquè en el següent frame ja no hi haurà velocitat + // d'acostament. + const float REL_VX = a->getIntendedVX() - b->getIntendedVX(); + const float REL_VY = a->getIntendedVY() - b->getIntendedVY(); + if (REL_VX == 0.0F && REL_VY == 0.0F) { return; } + a->setIntendedVelocity(-a->getIntendedVX(), -a->getIntendedVY()); + b->setIntendedVelocity(-b->getIntendedVX(), -b->getIntendedVY()); + return; + } + const bool MOVING_A = (a->getInputVX() != 0.0F) || (a->getInputVY() != 0.0F); const bool MOVING_B = (b->getInputVX() != 0.0F) || (b->getInputVY() != 0.0F); From e8407ce35e6fba2d2cb3c3277da6eeda030e53f4 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sat, 25 Apr 2026 14:56:15 +0200 Subject: [PATCH 13/19] =?UTF-8?q?feat(player):=20nou=20estat=20HURT=20?= =?UTF-8?q?=E2=80=94=20caure=20damunt=20d'un=20altre=20el=20llan=C3=A7a=20?= =?UTF-8?q?projectat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/player/morcus.yaml | 24 +++ data/player/sam.yaml | 24 +++ source/game/entities/player.cpp | 308 ++++++++++++++++++++---------- source/game/entities/player.hpp | 68 +++++-- source/game/scenes/game_scene.cpp | 5 + 5 files changed, 310 insertions(+), 119 deletions(-) diff --git a/data/player/morcus.yaml b/data/player/morcus.yaml index 571c76a..00a17e2 100644 --- a/data/player/morcus.yaml +++ b/data/player/morcus.yaml @@ -56,6 +56,18 @@ animations: frames: [8, 9, 10, 11] frame_ms: 100 + hurt_down: + frames: [0, 1, 2, 3] + frame_ms: 100 + + hurt_up: + frames: [4, 5, 6, 7] + frame_ms: 100 + + hurt: + frames: [8, 9, 10, 11] + frame_ms: 100 + # Inèrcia només de frenada. L'acceleració és sempre instantània (soltar # el teclat + prémer una direcció = velocitat plena); en canvi, quan es # solta la direcció el jugador decau linealment a 0 en `brake_time_ms`. @@ -103,3 +115,15 @@ jump: shadow_y_offset: 3 sound: jump.wav land_sound: land.wav + +# Estat HURT: el jugador queda projectat sense control per un impacte +# (algú que cau al damunt; en el futur també fuetada de l'altre jugador). +# `enterHurt` tria automàticament la direcció amb més recorregut lliure +# entre els 4 cardinals, posa la velocitat inicial a `speed` i la fa +# decaure linealment fins a 0 en `brake_time_ms`. Si pel camí xoca amb +# un mur, para en sec. En tornar a velocitat 0, recupera l'estat +# GROUNDED i el control. Mentre dura, no col·lisiona amb altres jugadors. +# Amb speed=120 i brake_time=500ms ⇒ recorregut ≈ 30 px (≈2 tiles). +hurt: + speed: 120 + brake_time_ms: 500 diff --git a/data/player/sam.yaml b/data/player/sam.yaml index 8599617..828fc93 100644 --- a/data/player/sam.yaml +++ b/data/player/sam.yaml @@ -56,6 +56,18 @@ animations: frames: [8, 9, 10, 11] frame_ms: 100 + hurt_down: + frames: [0, 1, 2, 3] + frame_ms: 100 + + hurt_up: + frames: [4, 5, 6, 7] + frame_ms: 100 + + hurt: + frames: [8, 9, 10, 11] + frame_ms: 100 + # Inèrcia només de frenada. L'acceleració és sempre instantània (soltar # el teclat + prémer una direcció = velocitat plena); en canvi, quan es # solta la direcció el jugador decau linealment a 0 en `brake_time_ms`. @@ -103,3 +115,15 @@ jump: shadow_y_offset: 3 sound: jump.wav land_sound: land.wav + +# Estat HURT: el jugador queda projectat sense control per un impacte +# (algú que cau al damunt; en el futur també fuetada de l'altre jugador). +# `enterHurt` tria automàticament la direcció amb més recorregut lliure +# entre els 4 cardinals, posa la velocitat inicial a `speed` i la fa +# decaure linealment fins a 0 en `brake_time_ms`. Si pel camí xoca amb +# un mur, para en sec. En tornar a velocitat 0, recupera l'estat +# GROUNDED i el control. Mentre dura, no col·lisiona amb altres jugadors. +# Amb speed=120 i brake_time=500ms ⇒ recorregut ≈ 30 px (≈2 tiles). +hurt: + speed: 120 + brake_time_ms: 500 diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index 3999d7a..bd77ab8 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -66,6 +67,7 @@ namespace { Player::TurnConfig Player::turn_config; Player::InertiaConfig Player::inertia_config; Player::JumpConfig Player::jump_config; +Player::HurtConfig Player::hurt_config; void Player::loadFrom(const std::string& yaml_path) { auto bytes = Resource::Helper::loadFile(yaml_path); @@ -108,6 +110,10 @@ void Player::loadFrom(const std::string& yaml_path) { // botó de salt no fa res. Extret a un mètode estàtic per acotar la // complexitat cognitiva de `loadFrom` sota el llindar de clang-tidy. readJumpBlock(yaml); + + // Bloc opcional `hurt:` — estat post-impacte. Si manca o el speed + // és 0, l'estat HURT mai s'activa (cas degenerat). + readHurtBlock(yaml); } void Player::readCollisionBlock(const fkyaml::node& yaml, const std::string& yaml_path) { @@ -161,6 +167,14 @@ void Player::readJumpBlock(const fkyaml::node& yaml) { } } +void Player::readHurtBlock(const fkyaml::node& yaml) { + hurt_config = HurtConfig{}; + if (!yaml.contains("hurt")) { return; } + const auto& h = yaml["hurt"]; + if (h.contains("speed")) { hurt_config.initial_speed_px_per_s = h["speed"].get_value(); } + if (h.contains("brake_time_ms")) { hurt_config.brake_time_ms = h["brake_time_ms"].get_value(); } +} + void Player::applyCardinal(Cardinal c) { switch (c) { case Cardinal::NORTH: @@ -323,7 +337,9 @@ auto Player::collidesWithWorld(const SDL_FRect& box) const -> bool { // acostar-se fins al contacte real. // // Regla de col·lisió jugador↔jugador (segons el feel triat): - // - Tots dos a terra → col·lisió normal. + // - 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. @@ -333,9 +349,11 @@ auto Player::collidesWithWorld(const SDL_FRect& box) const -> bool { // mateixa franja d'alçada. const SDL_FRect PROPOSED_HIT = hitBoxFromBase(box); const bool MY_AIR = (state_ == State::AIRBORNE); + const bool MY_HURT = (state_ == State::HURT); const float MY_Z = jump_z_; - return std::ranges::any_of(*all_players_, [this, &PROPOSED_HIT, MY_AIR, MY_Z](const Player* p) { + return std::ranges::any_of(*all_players_, [this, &PROPOSED_HIT, MY_AIR, MY_HURT, MY_Z](const Player* p) { if (p == nullptr || p == this) { return false; } + if (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, @@ -422,6 +440,36 @@ void Player::tick(float delta_time) { return; } + // En HURT el jugador està projectat sense control: descomptem + // velocitat per inèrcia (decaiment lineal fins a 0). Quan arriba a + // zero, tornem a GROUNDED i el frame següent ja torna a llegir input. + // El moviment real l'aplica `applyMovement` (igual que els altres + // estats), que també gestiona el xoc contra murs. + if (state_ == State::HURT) { + (void)consumeJumpEdge(); + const float DECEL = (hurt_config.brake_time_ms > 0) + ? hurt_config.initial_speed_px_per_s / (static_cast(hurt_config.brake_time_ms) / 1000.0F) + : 0.0F; + const float STEP = DECEL * delta_time; + auto decay = [STEP](float& v) { + if (std::fabs(v) <= STEP) { + v = 0.0F; + } else { + v -= std::copysign(STEP, v); + } + }; + decay(intended_vx_); + decay(intended_vy_); + if (intended_vx_ == 0.0F && intended_vy_ == 0.0F) { + state_ = State::GROUNDED; + } + input_vx_ = 0.0F; + input_vy_ = 0.0F; + setVelX(intended_vx_); + setVelY(intended_vy_); + return; + } + // Via `InputState` en lloc de `SDL_GetKeyboardState` directe: quan el // service menu (o qualsevol UI modal) està obert, el wrapper retorna // un buffer tot-a-zero perquè el jugador no es mogui mentre l'usuari @@ -522,6 +570,79 @@ void Player::startJump() { // tocaran fins a aterrar. } +void Player::enterHurt() { + // 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; } + + // Triem la direcció amb més recorregut lliure: provem els 4 + // cardinals i ens quedem el de major distància contra muros/sarcs. + // El test no comprova jugadors (durant HURT el jugador és + // intangible al PvP), només geometria del mapa. + constexpr int MAX_PX = 32; // 2 tiles, prou per a la cerca; la inèrcia limita el recorregut real. + struct Candidate { + Cardinal dir; + float dx; + float dy; + }; + constexpr std::array ALL{{ + {.dir = Cardinal::EAST, .dx = 1.0F, .dy = 0.0F}, + {.dir = Cardinal::WEST, .dx = -1.0F, .dy = 0.0F}, + {.dir = Cardinal::NORTH, .dx = 0.0F, .dy = -1.0F}, + {.dir = Cardinal::SOUTH, .dx = 0.0F, .dy = 1.0F}, + }}; + const auto BASE = getBaseBox(); + int best_reach = -1; + Cardinal best_dir = Cardinal::EAST; + for (const auto& c : ALL) { + int reach = MAX_PX; + for (int i = 1; i <= MAX_PX; ++i) { + SDL_FRect probe = BASE; + probe.x += static_cast(i) * c.dx; + probe.y += static_cast(i) * c.dy; + if (map_.collides(probe) || !map_.inside_playfield(probe)) { + reach = i - 1; + break; + } + } + if (reach > best_reach) { + best_reach = reach; + best_dir = c.dir; + } + } + + // Activem l'estat i fixem la velocitat segons la direcció triada. + state_ = State::HURT; + applyCardinal(best_dir); + last_input_cardinal_ = best_dir; + 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; + intended_vx_ = 0.0F; + intended_vy_ = 0.0F; + switch (best_dir) { + case Cardinal::EAST: + intended_vx_ = V; + break; + case Cardinal::WEST: + intended_vx_ = -V; + break; + case Cardinal::NORTH: + intended_vy_ = -V; + break; + case Cardinal::SOUTH: + intended_vy_ = V; + break; + case Cardinal::NONE: + break; + } + 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; @@ -540,73 +661,22 @@ auto Player::integrateJump(float delta_time) -> bool { } 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; } - // Usem la BASE-box (16×16, el cos sencer) per al test, no la - // hit-box (8×8 de contacte). Volem separar els cossos - // visualment: si fem servir només la hit-box, dos jugadors - // poden quedar amb les base-boxes solapades a mig sprite (un - // dins de l'altre) tot i tenir les hit-boxes ja apartades. La - // base-box dóna la separació real del cos. + // Test amb la BASE-box (cos sencer): si els cossos solapen, + // l'altre es projecta. Usem la base-box i no la hit-box perquè + // ens importa la separació visual completa, no només els + // píxels de contacte. const SDL_FRect MY_BOX = getBaseBox(); const SDL_FRect OT_BOX = other->getBaseBox(); - SDL_FRect inter; - if (!SDL_GetRectIntersectionFloat(&MY_BOX, &OT_BOX, &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_BOX.x + (MY_BOX.w * 0.5F); - const float MCY = MY_BOX.y + (MY_BOX.h * 0.5F); - const float OCX = OT_BOX.x + (OT_BOX.w * 0.5F); - const float OCY = OT_BOX.y + (OT_BOX.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); + if (!SDL_HasRectIntersectionFloat(&MY_BOX, &OT_BOX)) { continue; } + other->enterHurt(); } } @@ -621,25 +691,13 @@ void Player::applyMovement(float 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::HURT) { + applyHurtMovement(delta_time); + return; + } + 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_, - }); - } - } + applyLandingStunMovement(delta_time); return; } @@ -689,30 +747,56 @@ void Player::applyMovement(float delta_time) { 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_, - }); - } + // Detecció de canvi de tile. Només publiquem en GROUNDED — en + // AIRBORNE el jugador no trepitja, i en HURT/LANDING_STUN les + // pròpies branques del switch ja crida la helper si toca. + if (state_ == State::GROUNDED) { publishTileEnteredIfChanged(); } +} + +void Player::applyHurtMovement(float delta_time) { + // En HURT el jugador es desplaça segons `intended_v*` (escrit a + // `enterHurt` i decremental a `tick`). Si `tryMove` no avança + // perquè topa amb un mur, el parem en sec i tornem a GROUNDED — + // honra la regla "salvo col·lisió, llavors para". + animate(delta_time); + const float FRAME_START_X = getPosX(); + const float FRAME_START_Y = getPosY(); + tryMove(intended_vx_ * delta_time, intended_vy_ * delta_time, [this](const SDL_FRect& box) { + return collidesWithWorld(box); + }); + clampToPlayfield(); + const bool MOVED = (getPosX() != FRAME_START_X) || (getPosY() != FRAME_START_Y); + const bool WANTED_TO_MOVE = (intended_vx_ != 0.0F) || (intended_vy_ != 0.0F); + if (!MOVED && WANTED_TO_MOVE) { + // Bloquejat per un mur → parem en sec i recuperem el control. + intended_vx_ = 0.0F; + intended_vy_ = 0.0F; + state_ = State::GROUNDED; } + setCurrentAnimation(animationName(true)); + publishTileEnteredIfChanged(); +} + +void Player::applyLandingStunMovement(float delta_time) { + // En LANDING_STUN no movem (la velocitat ja és 0). Animem per + // mantenir el frame del stun i emetem la tile d'aterratge perquè + // es pinte. + animate(delta_time); + setCurrentAnimation(animationName(false)); + publishTileEnteredIfChanged(); +} + +void Player::publishTileEnteredIfChanged() { + if (bus_ == nullptr) { return; } + const auto [COL, ROW] = getCurrentTile(); + if (COL == last_tile_col_ && ROW == last_tile_row_) { return; } + last_tile_col_ = COL; + last_tile_row_ = ROW; + bus_->publish(Event{ + .name = "on_tile_entered", + .payload = PaintedTile{.col = COL, .row = ROW, .tile_index = paint_tile_index_}, + .player_id = id_, + }); } void Player::render() { @@ -799,6 +883,20 @@ auto Player::animationName(bool moving) const -> const char* { } return "stun"; } + // En HURT, `hurt_*` (per direcció): el jugador llisca sense + // control en la direcció triada per `enterHurt`. Mateixa estructura + // que walk per facilitat (ara mateix els frames són clons). + if (state_ == State::HURT) { + switch (facing_) { + case Facing::UP: + return "hurt_up"; + case Facing::DOWN: + return "hurt_down"; + case Facing::SIDE: + break; + } + return "hurt"; + } switch (facing_) { case Facing::UP: return moving ? "walk_up" : "stand_up"; diff --git a/source/game/entities/player.hpp b/source/game/entities/player.hpp index d254ed1..40bbdae 100644 --- a/source/game/entities/player.hpp +++ b/source/game/entities/player.hpp @@ -28,15 +28,22 @@ class Player : public AnimatedSprite { // 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. + // fer el jugador aquest frame: + // GROUNDED → input normal, pot saltar. + // AIRBORNE → física del salt; ignora input direccional, sense + // col·lisió amb caminants però sí amb altres + // saltadors a la mateixa franja d'alçada (rebot). + // LANDING_STUN → 200ms post-aterratge, sense input ni moviment. + // HURT → projectat sense control (cau d'algú al damunt o, + // en el futur, fuetada): velocitat inicial alta + // que decau per inèrcia fins a parar (~2 tiles). + // No col·lisiona amb cap altre jugador. Quan + // s'atura, torna a GROUNDED. enum class State : std::uint8_t { GROUNDED, AIRBORNE, LANDING_STUN, + HURT, }; // Geometria carregada des del YAML del player (p.ex. `data/player/sam.yaml`). @@ -169,6 +176,12 @@ class Player : public AnimatedSprite { // GameScene (per saber si renderitzar la sombra). [[nodiscard]] auto getState() const -> State { return state_; } [[nodiscard]] auto isAirborne() const -> bool { return state_ == State::AIRBORNE; } + [[nodiscard]] auto isHurt() const -> bool { return state_ == State::HURT; } + // Tangible per a contactes jugador↔jugador: només els estats a terra + // amb cos sòlid. AIRBORNE i HURT són intangibles (es travessen). + [[nodiscard]] auto isPvpTangible() const -> bool { + return state_ == State::GROUNDED || state_ == State::LANDING_STUN; + } // 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 @@ -385,6 +398,27 @@ class Player : public AnimatedSprite { // manca, deixa la mecànica del salt desactivada. static void readJumpBlock(const fkyaml::node& yaml); + // Configuració de l'estat HURT (estat post-impacte). Si + // `initial_speed_px_per_s == 0` el jugador entra i surt + // instantàniament (cap moviment). + struct HurtConfig { + float initial_speed_px_per_s{0.0F}; // Velocitat amb què surt projectat (px/s). + int brake_time_ms{0}; // Temps en que la velocitat decau a 0. + }; + static HurtConfig hurt_config; + + // Llig el bloc `hurt:` del YAML i n'omple `hurt_config`. + static void readHurtBlock(const fkyaml::node& yaml); + + // Activa l'estat HURT: el jugador queda projectat amb velocitat + // inicial `hurt_config.initial_speed_px_per_s` cap a la direcció que + // doni més recorregut lliure (provem els 4 cardinals i triem el de + // major distància fins a topar amb un mur). Decelera linealment fins + // a parar i, en eixe instant, torna a GROUNDED. Sense col·lisió amb + // altres jugadors mentre dura — ja que es travessa l'eventual + // solapament inicial. + void enterHurt(); + // Estat de la màquina d'estats. Mutat per `tick`/`applyMovement`. Per // defecte GROUNDED al spawn. State state_{State::GROUNDED}; @@ -418,17 +452,23 @@ class Player : public AnimatedSprite { // `applyMovement` puga disparar la resolució del solapament PvP). auto integrateJump(float delta_time) -> bool; - // En aterrar, si la hit-box del jugador solapa amb la d'un altre, - // separa els dos sense ficar-los dins d'un mur. Algoritme: - // 1. Calcula la direcció centre→centre i la magnitud de - // solapament (MTV axis-aligned). - // 2. Empenta l'altre cap a fora; si no cap (mur al darrere), - // reverteix i empenta el saltador cap al sentit oposat. - // 3. Si tampoc cap, es deixa l'estat com està (overlap residual); - // a la pràctica aquest cas és molt rar i el usuari ja ha - // acceptat que no és crític. + // En aterrar, si la base-box del jugador solapa amb la d'un altre, + // l'altre entra en `HURT` i surt projectat per `enterHurt`. El + // saltador (this) es queda al lloc — el moviment per separar-los + // el fa el HURT en cours, no una empenta forçada. void resolveLandingOverlap(); + // Branques de `applyMovement` extretes per acotar la complexitat + // cognitiva. Cada estat especial gestiona el seu propi cicle + // (animació, tryMove o no, publicació de tile). + void applyHurtMovement(float delta_time); + void applyLandingStunMovement(float delta_time); + + // Publica `on_tile_entered` al bus si el tile ha canviat respecte a + // l'últim publicat. Comú a totes les branques de `applyMovement` que + // pinten el sòl (GROUNDED, HURT, LANDING_STUN; en AIRBORNE no). + void publishTileEnteredIfChanged(); + // 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 579ce18..4d5083f 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -106,6 +106,11 @@ namespace { const bool A_AIR = a->isAirborne(); const bool B_AIR = b->isAirborne(); + // HURT a un costat o l'altre: el resolver no s'activa — el HURT + // surt projectat sense control i els dos es travessen. Coincideix + // amb la regla d'`Player::collidesWithWorld`. + if (a->isHurt() || b->isHurt()) { return; } + // Aire ↔ tierra: el saltador es travessa net del caminant. Sense // resolver, sense head-on, sense push. Els dos continuen amb la // velocitat que portaven. Important: això NO és el filtre per From 7f78f556f3ff50e7953d3cbdbaf627829d51bc8d Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sat, 25 Apr 2026 15:01:56 +0200 Subject: [PATCH 14/19] fix(player): el disparador del HURT en aterrar usa la hit-box, no la base-box --- source/game/entities/player.cpp | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index bd77ab8..42b4e3b 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -669,13 +669,15 @@ void Player::resolveLandingOverlap() { if (all_players_ == nullptr) { return; } for (Player* other : *all_players_) { if (other == nullptr || other == this) { continue; } - // Test amb la BASE-box (cos sencer): si els cossos solapen, - // l'altre es projecta. Usem la base-box i no la hit-box perquè - // ens importa la separació visual completa, no només els - // píxels de contacte. - const SDL_FRect MY_BOX = getBaseBox(); - const SDL_FRect OT_BOX = other->getBaseBox(); - if (!SDL_HasRectIntersectionFloat(&MY_BOX, &OT_BOX)) { 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; } other->enterHurt(); } } From 4667e0abf987ce06bbb223a97b0b9920b50bf50c Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sat, 25 Apr 2026 15:17:45 +0200 Subject: [PATCH 15/19] =?UTF-8?q?fix(player):=20saltar=20enmig=20d'un=20gi?= =?UTF-8?q?r=20de=20180=C2=B0=20comprometeix=20la=20direcci=C3=B3=20final?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/player/sam.yaml | 2 +- source/game/entities/player.cpp | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/data/player/sam.yaml b/data/player/sam.yaml index 828fc93..a685f11 100644 --- a/data/player/sam.yaml +++ b/data/player/sam.yaml @@ -111,7 +111,7 @@ turn: jump: impulse: 200 gravity: 600 - landing_stun_ms: 200 + landing_stun_ms: 300 shadow_y_offset: 3 sound: jump.wav land_sound: land.wav diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index 42b4e3b..1f1e23b 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -559,6 +559,20 @@ void Player::startJump() { // empentant en mig d'un frame on intentàvem saltar, el flanc es perd // — comportament acceptable: l'empenta guanya al toc curt. if (state_ != State::GROUNDED) { return; } + + // Un gir de 180° en curs es compromet immediatament: si l'usuari + // ha disparat un giro (oest→est, p.ex.) i salta abans que s'esgote + // el timer, l'animació del salt ha de mostrar la direcció final + // (jump lateral), no la intermèdia (jump_down). Aplem el pending + // ara mateix i resetem l'estat del gir. Si no hi havia gir en + // curs, és no-op. + if (turning_remaining_ms_ > 0.0F) { + applyCardinal(pending_cardinal_); + turning_remaining_ms_ = 0.0F; + pending_cardinal_ = Cardinal::NONE; + intermediate_cardinal_ = Cardinal::NONE; + } + state_ = State::AIRBORNE; jump_z_ = 0.0F; jump_vz_ = jump_config.impulse_px_per_s; From c38c80753af72f34a9aa5f17835a44d4a4707593 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sat, 25 Apr 2026 15:29:48 +0200 Subject: [PATCH 16/19] =?UTF-8?q?feat(player):=20direcci=C3=B3=20preferida?= =?UTF-8?q?=20al=20HURT=20segons=20l'offset=20d'impacte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- source/game/entities/player.cpp | 141 +++++++++++++++++++------------- source/game/entities/player.hpp | 31 +++++-- 2 files changed, 108 insertions(+), 64 deletions(-) diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index 1f1e23b..5762236 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -584,74 +584,81 @@ void Player::startJump() { // tocaran fins a aterrar. } -void Player::enterHurt() { +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; } - // Triem la direcció amb més recorregut lliure: provem els 4 - // cardinals i ens quedem el de major distància contra muros/sarcs. - // El test no comprova jugadors (durant HURT el jugador és - // intangible al PvP), només geometria del mapa. - constexpr int MAX_PX = 32; // 2 tiles, prou per a la cerca; la inèrcia limita el recorregut real. - struct Candidate { - Cardinal dir; - float dx; - float dy; - }; - constexpr std::array ALL{{ - {.dir = Cardinal::EAST, .dx = 1.0F, .dy = 0.0F}, - {.dir = Cardinal::WEST, .dx = -1.0F, .dy = 0.0F}, - {.dir = Cardinal::NORTH, .dx = 0.0F, .dy = -1.0F}, - {.dir = Cardinal::SOUTH, .dx = 0.0F, .dy = 1.0F}, - }}; - const auto BASE = getBaseBox(); - int best_reach = -1; - Cardinal best_dir = Cardinal::EAST; - for (const auto& c : ALL) { - int reach = MAX_PX; - for (int i = 1; i <= MAX_PX; ++i) { - SDL_FRect probe = BASE; - probe.x += static_cast(i) * c.dx; - probe.y += static_cast(i) * c.dy; - if (map_.collides(probe) || !map_.inside_playfield(probe)) { - reach = i - 1; - break; - } - } - if (reach > best_reach) { - best_reach = reach; - best_dir = c.dir; - } - } + const Cardinal CHOSEN = chooseHurtDirection(preferred); // Activem l'estat i fixem la velocitat segons la direcció triada. state_ = State::HURT; - applyCardinal(best_dir); - last_input_cardinal_ = best_dir; + 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; - intended_vx_ = 0.0F; - intended_vy_ = 0.0F; - switch (best_dir) { - case Cardinal::EAST: - intended_vx_ = V; - break; - case Cardinal::WEST: - intended_vx_ = -V; - break; - case Cardinal::NORTH: - intended_vy_ = -V; - break; - case Cardinal::SOUTH: - intended_vy_ = V; - break; - case Cardinal::NONE: - break; - } + 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; @@ -692,7 +699,27 @@ void Player::resolveLandingOverlap() { const SDL_FRect MY_HIT = getHitBox(); const SDL_FRect OT_HIT = other->getHitBox(); if (!SDL_HasRectIntersectionFloat(&MY_HIT, &OT_HIT)) { continue; } - other->enterHurt(); + + // 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); } } diff --git a/source/game/entities/player.hpp b/source/game/entities/player.hpp index 40bbdae..b5ebc55 100644 --- a/source/game/entities/player.hpp +++ b/source/game/entities/player.hpp @@ -411,13 +411,30 @@ class Player : public AnimatedSprite { static void readHurtBlock(const fkyaml::node& yaml); // Activa l'estat HURT: el jugador queda projectat amb velocitat - // inicial `hurt_config.initial_speed_px_per_s` cap a la direcció que - // doni més recorregut lliure (provem els 4 cardinals i triem el de - // major distància fins a topar amb un mur). Decelera linealment fins - // a parar i, en eixe instant, torna a GROUNDED. Sense col·lisió amb - // altres jugadors mentre dura — ja que es travessa l'eventual - // solapament inicial. - void enterHurt(); + // inicial `hurt_config.initial_speed_px_per_s`. La direcció es tria + // així: + // - Si `preferred != NONE` i hi cap el recorregut natural complet + // (sense topar amb mur abans d'esgotar la inèrcia), s'usa + // `preferred`. + // - Altrament (o si `preferred == NONE`), prova els 4 cardinals + // i tria el que dóna més recorregut lliure. + // Decelera linealment fins a parar i, en eixe instant, torna a + // GROUNDED. Sense col·lisió amb altres jugadors mentre dura. + void enterHurt(Cardinal preferred = Cardinal::NONE); + + // Tria la direcció definitiva del HURT. Si `preferred` cabe el + // recorregut natural complet, la retorna; d'altra manera tria la + // direcció amb més recorregut lliure entre els 4 cardinals. + [[nodiscard]] auto chooseHurtDirection(Cardinal preferred) const -> Cardinal; + + // Recorregut lliure (en píxels, fins a `max_px`) en la direcció + // (dx, dy) abans de topar amb un mur o eixir del playfield. La + // base-box es projecta `i` píxels i es comprova; retorna `i-1` al + // primer xoc. + [[nodiscard]] auto reachInDirection(float dx, float dy, int max_px) const -> int; + + // Vectors unitaris dels 4 cardinals. `NONE` retorna (0, 0). + [[nodiscard]] static auto deltasFor(Cardinal c) -> std::pair; // Estat de la màquina d'estats. Mutat per `tick`/`applyMovement`. Per // defecte GROUNDED al spawn. From 3e379ce1295206e6272515fde1468025fab1a8fd Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sat, 25 Apr 2026 17:51:55 +0200 Subject: [PATCH 17/19] feat(effects): polvet transitori al despegue i a l'aterratge --- CMakeLists.txt | 1 + data/entities/dust/dust.gif | Bin 0 -> 307 bytes data/entities/dust/dust.yaml | 22 +++++++++ source/game/entities/concrete/dust.cpp | 57 ++++++++++++++++++++++++ source/game/entities/concrete/dust.hpp | 36 +++++++++++++++ source/game/entities/entity.hpp | 9 ++++ source/game/entities/entity_manager.cpp | 9 ++++ source/game/entities/event_bus.hpp | 13 +++++- source/game/entities/player.cpp | 16 +++++++ source/game/scenes/game_scene.cpp | 41 +++++++++++++++++ source/game/scenes/game_scene.hpp | 6 +++ 11 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 data/entities/dust/dust.gif create mode 100644 data/entities/dust/dust.yaml create mode 100644 source/game/entities/concrete/dust.cpp create mode 100644 source/game/entities/concrete/dust.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 49bcd8b..a6a777c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -47,6 +47,7 @@ set(ENTITY_SOURCES source/game/entities/concrete/rolling_rock.cpp source/game/entities/concrete/wall_trap.cpp source/game/entities/concrete/floor_trap.cpp + source/game/entities/concrete/dust.cpp ) set(APP_SOURCES diff --git a/data/entities/dust/dust.gif b/data/entities/dust/dust.gif new file mode 100644 index 0000000000000000000000000000000000000000..75058802101c8ff20160352206bdefdf5d59aa0d GIT binary patch literal 307 zcmV-30nGkKNk%v~VHg1*0FeLy0001^r^4Uh=l}o!A^!_bMO0HmK~P09E-(WD0000X z`2-0H0000i000007y%#v00QxZkEzS;52Kv4+KaQ^y!#J^;z*X}iKgnxw(bkZ@=VwE zjpzE#_x=wI3VQ-pafnPNj!F`f@|;8?Q)+c|ZNdau4;Sz)g|)b@!)#!VoyRrA{YER+ z@TGh~f7B)S^pI-?nAUeNwl|1jNC+jfSn=e@xFQHvMVY7;X+fyD+4)H3RCv{96ncu9 zs=CVB>iP;BD?3YDYkP~EtGmnF>-!5FEIdqHY +#include + +#include "game/entities/entity_factory.hpp" + +namespace { + + // Llig una animació opcional del bloc `animations` del YAML, amb + // valors per defecte segurs (1 frame, fps=1, sense loop). Si la + // clau no existeix, retornem una animació "ja completada" — així, + // un Dust spawnejat amb una variant inexistent al YAML mor al + // pròxim frame sense petar. + auto buildAnim(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent { + std::vector frames = {0}; + float fps = 1.0F; + bool loop = false; + if (yaml.contains("animations") && yaml["animations"].contains(key)) { + const auto& anim = yaml["animations"][key]; + if (anim.contains("frames")) { + frames.clear(); + for (const auto& f : anim["frames"]) { + // cppcheck-suppress useStlAlgorithm + frames.push_back(f.get_value()); + } + if (frames.empty()) { frames.push_back(0); } + } + if (anim.contains("fps")) { fps = anim["fps"].get_value(); } + if (anim.contains("loop")) { loop = anim["loop"].get_value(); } + } + return {frames, fps, loop}; + } + +} // namespace + +Dust::Dust(const fkyaml::node& yaml) + : anim_jump_(buildAnim(yaml, "jump")), + anim_land_(buildAnim(yaml, "land")) { + name_ = yaml.contains("name") ? yaml["name"].get_value() : std::string("dust"); + collides_ = false; + interactable_ = false; + // Per defecte JUMP; el spawner pot canviar-la abans del primer tick. + anim_ = anim_jump_; +} + +void Dust::setVariant(Variant v) { + anim_ = (v == Variant::JUMP) ? anim_jump_ : anim_land_; + if (anim_.has_value()) { anim_->reset(); } +} + +void Dust::update(float delta_time) { + Entity::update(delta_time); + if (anim_.has_value() && anim_->completed()) { markDead(); } +} + +REGISTER_ENTITY("dust", Dust) diff --git a/source/game/entities/concrete/dust.hpp b/source/game/entities/concrete/dust.hpp new file mode 100644 index 0000000..169ed14 --- /dev/null +++ b/source/game/entities/concrete/dust.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include + +#include "external/fkyaml_node.hpp" +#include "game/entities/animation_component.hpp" +#include "game/entities/entity.hpp" + +// Polvet transitori: efecte purament visual que viu una animació i +// s'autoelimina (`markDead`) quan acaba. Spawneja el `GameScene` com a +// reacció a events `player_jumped` i `player_landed` del bus. Les dues +// variants (despegue / aterratge) viuen al mateix YAML amb dues +// animacions distintes (`jump` i `land`); l'instància tria una via +// `setVariant` just després de construir-se. +class Dust : public Entity { + public: + enum class Variant : std::uint8_t { + JUMP, + LAND, + }; + + explicit Dust(const fkyaml::node& yaml); + + // Tria quina animació es reproduirà. Cal cridar-lo abans del primer + // `update`; per defecte la variant és JUMP. + void setVariant(Variant v); + + // Tickeja l'animació activa. Quan arriba a `completed()`, marquem + // l'entitat morta perquè l'`EntityManager` la purgue al final del + // frame. + void update(float delta_time) override; + + private: + AnimationComponent anim_jump_; + AnimationComponent anim_land_; +}; diff --git a/source/game/entities/entity.hpp b/source/game/entities/entity.hpp index ca7a1c5..6d034ec 100644 --- a/source/game/entities/entity.hpp +++ b/source/game/entities/entity.hpp @@ -88,6 +88,14 @@ class Entity { [[nodiscard]] auto collides() const -> bool { return collides_; } [[nodiscard]] auto interactable() const -> bool { return interactable_; } + // Marca per a entitats transitòries: efectes (polvet, espurnes, + // explosions...) que viuen una sola animació i s'autoeliminen quan + // acaba. L'`EntityManager` purga totes les entitats amb `dead_=true` + // al final de cada `update`. Les entitats persistents (sarcs, momies, + // portes, jugadors) mai no la posen i hi viuen sempre. + [[nodiscard]] auto isDead() const -> bool { return dead_; } + void markDead() { dead_ = true; } + // Associar el bus una sola vegada (propietat externa, p.ex. // `EntityManager`). Entitats que no publiquen events (Torch, trampes // estàtiques de moment) el poden ignorar. @@ -162,6 +170,7 @@ class Entity { bool collides_{false}; bool interactable_{false}; int height_{0}; + bool dead_{false}; SDL_FlipMode flip_{SDL_FLIP_NONE}; EventBus* bus_{nullptr}; }; diff --git a/source/game/entities/entity_manager.cpp b/source/game/entities/entity_manager.cpp index dbfeed7..01125fd 100644 --- a/source/game/entities/entity_manager.cpp +++ b/source/game/entities/entity_manager.cpp @@ -64,7 +64,16 @@ auto EntityManager::addSarcophagus(std::unique_ptr sarc) -> Sarcoph } void EntityManager::update(float delta_time) { + // ATENCIÓ: si en el futur una entitat publica al bus events que + // resulten en `entity_manager_.add()` (push_back a `entities_`), açò + // invalidaria l'iterador de la ranged-for. Avui no passa: els spawns + // runtime venen de fora del cicle d'`update` (p.ex. el polvet del + // saltador es spawneja durant `Player::tick`, abans d'aquest mètode). for (auto& e : entities_) { e->update(delta_time); } + // Purga d'entitats marcades mortes aquest frame (efectes acabats, + // p.ex. polvet d'aterratge). Les persistents (sarcs, momies, etc.) + // mai no es marquen. + std::erase_if(entities_, [](const auto& e) { return e->isDead(); }); } void EntityManager::notifyPlayerTilePainted(int col, int row) { diff --git a/source/game/entities/event_bus.hpp b/source/game/entities/event_bus.hpp index fd7789e..88b8e64 100644 --- a/source/game/entities/event_bus.hpp +++ b/source/game/entities/event_bus.hpp @@ -41,9 +41,17 @@ struct PaintedTile { int tile_index{-1}; }; +// Coordenades en píxels al canvas. Payload genèric per a events que +// volen indicar "una cosa ha passat aquí" — actualment `player_jumped` +// i `player_landed`, que el GameScene escolta per spawnejar polvet. +struct WorldPos { + float x{0.0F}; + float y{0.0F}; +}; + struct Event { std::string name; - using Payload = std::variant; + using Payload = std::variant; Payload payload; // Jugador que ha originat (o provocat) l'event. -1 = sense jugador // associat (events del món, com `sarcophagus_opened` des de la pròpia @@ -90,6 +98,9 @@ class EventBus { void publish(const std::string& name, PaintedTile painted) { publish(Event{.name = name, .payload = painted}); } + void publish(const std::string& name, WorldPos pos) { + publish(Event{.name = name, .payload = pos}); + } private: struct Subscription { diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index 5762236..fc97b5c 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -579,6 +579,15 @@ void Player::startJump() { 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. @@ -676,6 +685,13 @@ auto Player::integrateJump(float delta_time) -> bool { if (!jump_config.land_sound_key.empty() && Audio::get() != nullptr) { Audio::get()->playSound(jump_config.land_sound_key, Audio::Group::GAME); } + 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; diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index 4d5083f..82c21c8 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -26,7 +26,9 @@ #include "core/system/event_buffer.hpp" #include "core/ui/ui_config.hpp" #include "external/fkyaml_node.hpp" +#include "game/entities/concrete/dust.hpp" #include "game/entities/entity.hpp" +#include "game/entities/entity_loader.hpp" #include "game/entities/event_bus.hpp" #include "game/entities/player.hpp" #include "game/map/map.hpp" @@ -273,6 +275,21 @@ GameScene::GameScene(Config config) << " dmg=" << (dmg != nullptr ? *dmg : 0) << '\n'; }); + // Polvet: precarreguem el surface del Dust una sola vegada perquè + // els spawns runtime de l'entitat no facin disc al primer salt. + // També ens subscrivim a `player_jumped` i `player_landed` per + // spawnejar el polvet de despegue / aterratge a la posició dels + // peus que el Player ens dóna al payload. + Resource::Cache::get().loadSurface("dust.gif", "data/entities/dust/dust.gif"); + entity_manager_.getBus().subscribe("player_jumped", [this](const Event& ev) { + const auto* pos = std::get_if(&ev.payload); + if (pos != nullptr) { spawnDust(pos->x, pos->y, false); } + }); + entity_manager_.getBus().subscribe("player_landed", [this](const Event& ev) { + const auto* pos = std::get_if(&ev.payload); + if (pos != nullptr) { spawnDust(pos->x, pos->y, true); } + }); + // Controls per jugador: P1 amb fletxes, P2 amb WASD. Cada Player // escolta les seues pròpies tecles dins del seu `update`, no hi ha // estat global d'input. @@ -382,6 +399,30 @@ GameScene::GameScene(Config config) GameScene::~GameScene() = default; +void GameScene::spawnDust(float feet_x_center, float feet_y, bool is_landing) { + // Mida del sprite de Dust llegida del seu YAML. Hardcoded per + // simplificar — si canvia, només cal tocar dust.yaml + aquesta + // constant. La caixa de render es centra horitzontalment al peu i + // s'alinea al sòl amb un pixel de marge cap amunt perquè la sombra + // quede pintada per davant del polvet i el polvet, en l'y-sort, + // entre per davant del jugador (mateixa lògica que la sombra). + constexpr float DUST_W = 40.0F; + constexpr float DUST_H = 16.0F; + auto dust = std::make_unique(EntityLoader::loadTemplate("dust")); + dust->setVariant(is_landing ? Dust::Variant::LAND : Dust::Variant::JUMP); + dust->setSurface(Resource::Cache::get().getSurface("dust.gif")); + // Recolzat al sòl, amb la base del rect 1 px per sobre del peu del + // jugador → en l'y-sort surt amb feet_y un pèl menor i, per + // estabilitat de stable_sort, queda darrere del jugador. + dust->setPixelRect({ + .x = feet_x_center - (DUST_W * 0.5F), + .y = feet_y - DUST_H - 1.0F, + .w = DUST_W, + .h = DUST_H, + }); + entity_manager_.add(std::move(dust)); +} + void GameScene::handlePauseInput() { #ifdef _DEBUG // Mutex amb l'editor: si està actiu, ignorem la tecla de pausa. diff --git a/source/game/scenes/game_scene.hpp b/source/game/scenes/game_scene.hpp index a82db53..f982164 100644 --- a/source/game/scenes/game_scene.hpp +++ b/source/game/scenes/game_scene.hpp @@ -56,6 +56,12 @@ class GameScene : public Scene { // (no escalat) perquè la transició viu en temps real. void updatePauseTransition(int delta_ms); + // Spawna un Dust transitori centrat a (`feet_x_center`, `feet_y`). + // Cridat per la subscripció del bus a `player_jumped` / + // `player_landed` per pintar el polvet de despegue / aterratge. + // `is_landing` selecciona la variant de l'animació al `Dust`. + void spawnDust(float feet_x_center, float feet_y, bool is_landing); + Config config_; Tilemap tilemap_; From abf52be7e05226ec09b0b076e20fbf2cf26b7b9b Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sat, 25 Apr 2026 18:06:06 +0200 Subject: [PATCH 18/19] =?UTF-8?q?tune(player+dust):=20ajustos=20finals=20d?= =?UTF-8?q?e=20timings=20i=20f=C3=ADsica?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/entities/dust/dust.gif | Bin 307 -> 306 bytes data/entities/dust/dust.yaml | 4 ++-- data/player/morcus.yaml | 2 +- data/player/sam.yaml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/data/entities/dust/dust.gif b/data/entities/dust/dust.gif index 75058802101c8ff20160352206bdefdf5d59aa0d..5aa1062374541ea0693936094cb27b172e101aba 100644 GIT binary patch delta 211 zcmV;^04)Er0=FOr3mpH%1mPIX}8>*b@%Wk zsNsiZvUCU})VHu_2$-_C7^zYT##lBrglU)w8Y((UT56HTAWTfC9Z%@hnY*%>fxgeegZ9957B24lQxckuKg{QT}cXuf9@0?LIG}Z;!i&Z$Y_dfBYFd Nh%lkTg$W7(06T8*TMqyL delta 212 zcmV;_04x8p0 Date: Sat, 25 Apr 2026 18:06:18 +0200 Subject: [PATCH 19/19] =?UTF-8?q?chore:=20bump=20v0.23.0=20=E2=80=94=20sis?= =?UTF-8?q?tema=20de=20salt,=20HURT=20i=20polvet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- source/utils/defines.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/utils/defines.hpp b/source/utils/defines.hpp index 3449980..2a6ae39 100644 --- a/source/utils/defines.hpp +++ b/source/utils/defines.hpp @@ -3,7 +3,7 @@ namespace Texts { constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte — JailDesigner"; constexpr const char* COPYRIGHT = "© 2026 JailDesigner"; - constexpr const char* VERSION = "0.22.0"; // Single source of truth — Makefile el grep + constexpr const char* VERSION = "0.23.0"; // Single source of truth — Makefile el grep } // namespace Texts namespace GameCanvas {