From e8407ce35e6fba2d2cb3c3277da6eeda030e53f4 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sat, 25 Apr 2026 14:56:15 +0200 Subject: [PATCH] =?UTF-8?q?feat(player):=20nou=20estat=20HURT=20=E2=80=94?= =?UTF-8?q?=20caure=20damunt=20d'un=20altre=20el=20llan=C3=A7a=20projectat?= 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