diff --git a/data/player/morcus.yaml b/data/player/morcus.yaml index 5d92dd0..a0c54ce 100644 --- a/data/player/morcus.yaml +++ b/data/player/morcus.yaml @@ -41,6 +41,19 @@ animations: loopFrom: 0 frames: [8, 9, 10, 11] +# 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`. +# El `threshold_ms` filtra els tocs curts: si el temps acumulat de +# moviment continu no ha arribat al llindar, la parada és instantània +# (permet "passets" d'un tile sense rellisquera). L'acumulador es +# reseteja només quan no hi ha ni input ni velocitat residual, així +# els canvis de direcció enmig del coast no rearmen el llindar. +# `brake_time_ms: 0` o absència del bloc = inèrcia desactivada. +inertia: + brake_time_ms: 150 + threshold_ms: 300 + # Gir de 180°: mentre dura, el facing queda fixat a la direcció # intermèdia (efecte purament visual; el moviment continua sense # alterar-se). Els girs de 90° no passen per cap intermedi. diff --git a/data/player/sam.yaml b/data/player/sam.yaml index b59e98f..894773a 100644 --- a/data/player/sam.yaml +++ b/data/player/sam.yaml @@ -41,6 +41,19 @@ animations: loopFrom: 0 frames: [8, 9, 10, 11] +# 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`. +# El `threshold_ms` filtra els tocs curts: si el temps acumulat de +# moviment continu no ha arribat al llindar, la parada és instantània +# (permet "passets" d'un tile sense rellisquera). L'acumulador es +# reseteja només quan no hi ha ni input ni velocitat residual, així +# els canvis de direcció enmig del coast no rearmen el llindar. +# `brake_time_ms: 0` o absència del bloc = inèrcia desactivada. +inertia: + brake_time_ms: 150 + threshold_ms: 300 + # Gir de 180°: mentre dura, el facing queda fixat a la direcció # intermèdia (efecte purament visual; el moviment continua sense # alterar-se). Els girs de 90° no passen per cap intermedi. diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index b33fc99..61bc514 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -49,6 +49,7 @@ namespace { } // namespace Player::TurnConfig Player::turn_config; +Player::InertiaConfig Player::inertia_config; void Player::loadFrom(const std::string& yaml_path) { auto bytes = Resource::Helper::loadFile(yaml_path); @@ -85,6 +86,15 @@ void Player::loadFrom(const std::string& yaml_path) { hit_x_offset = collision_x_offset + HIT_INSET; hit_y_offset = collision_y_offset + HIT_INSET; + // Bloc opcional `inertia:` — si manca, `brake_time_ms` queda a 0 i + // la inèrcia de frenada no s'activa (parada instantània com abans). + inertia_config = InertiaConfig{}; + if (yaml.contains("inertia")) { + const auto& i = yaml["inertia"]; + if (i.contains("brake_time_ms")) { inertia_config.brake_time_ms = i["brake_time_ms"].get_value(); } + if (i.contains("threshold_ms")) { inertia_config.threshold_ms = i["threshold_ms"].get_value(); } + } + // Bloc opcional `turn:` — si manca, `duration_ms` queda a 0 i la // mecànica del gir de 180° no s'activa (canvis de direcció instantanis). turn_config = TurnConfig{}; @@ -269,6 +279,38 @@ auto Player::collidesWithWorld(const SDL_FRect& box) const -> bool { }); } +void Player::integrateAxis(float& phys, float input, float delta_time, float motion_time_ms, bool any_input) { + // Acceleració instantània: mentre hi ha input, la velocitat salta a + // ±SPEED sense transició. Cobreix també els canvis de direcció (un + // coast cap a la dreta passa a -SPEED immediatament si es prem esquerra). + if (input != 0.0F) { + phys = input; + return; + } + if (phys == 0.0F) { return; } + // Hi ha velocitat residual i aquest eix no té input. Tres camins: + // · Input a l'altre eix (gir de 90°): parada en sec — no arrosseguem + // en diagonal mentre l'usuari està premant una altra direcció. + // · Input solta del tot i llindar superat: inèrcia (decaiment lineal). + // · Input solta del tot i llindar NO superat: parada en sec (passets). + if (any_input) { + phys = 0.0F; + return; + } + const bool INERTIA_ACTIVE = (inertia_config.brake_time_ms > 0) && (motion_time_ms >= static_cast(inertia_config.threshold_ms)); + if (!INERTIA_ACTIVE) { + phys = 0.0F; + return; + } + const float DECEL = SPEED_PX_PER_S / (static_cast(inertia_config.brake_time_ms) / 1000.0F); + const float STEP = DECEL * delta_time; + if (std::fabs(phys) <= STEP) { + phys = 0.0F; + } else { + phys -= std::copysign(STEP, phys); + } +} + void Player::tick(float delta_time) { // Reset per frame: el resolver del GameScene el tornarà a posar a // true si aquest frame hi ha push actiu. Així quan el push acaba @@ -292,12 +334,39 @@ void Player::tick(float delta_time) { // els botons tornen false/0 i no contribueixen a la velocitat. accumulateGamepadMovement(id_, vx, vy); - intended_vx_ = vx; - intended_vy_ = vy; - setVelX(vx); - setVelY(vy); + input_vx_ = vx; + input_vy_ = vy; - updateFacingAndTurn(delta_time, vx, vy); + // Integració física per eix. `intended_v*` fa de memòria entre frames: + // si l'usuari té input, se sobreescriu a ±SPEED; si no, decau per + // inèrcia o es posa a 0. El `motion_time_ms_` que passem és el del + // final del frame anterior — serveix per decidir si la parada d'aquest + // frame és suau (llindar superat) o en sec. HAS_INPUT bloca la inèrcia + // en girs de 90° (un eix a 0 amb l'altre actiu no ha de "patinar"). + const bool HAS_INPUT = (input_vx_ != 0.0F) || (input_vy_ != 0.0F); + integrateAxis(intended_vx_, input_vx_, delta_time, motion_time_ms_, HAS_INPUT); + integrateAxis(intended_vy_, input_vy_, delta_time, motion_time_ms_, HAS_INPUT); + + // Actualitzem l'acumulador DESPRÉS de la integració. Creix mentre hi + // haja intenció (input) o moviment residual (coast); només es buida + // quan el jugador està genuïnament aturat (ni input ni velocitat). Així + // un canvi de direcció enmig del coast o un toc curt contra el coast + // no rearma mai el llindar. + const bool HAS_VELOCITY = (intended_vx_ != 0.0F) || (intended_vy_ != 0.0F); + if (HAS_INPUT || HAS_VELOCITY) { + motion_time_ms_ += delta_time * 1000.0F; + } else { + motion_time_ms_ = 0.0F; + } + + setVelX(intended_vx_); + setVelY(intended_vy_); + + // Important: el facing i la detecció de gir de 180° s'alimenten amb + // input CRU, no amb la velocitat física. Un coast sense input NO + // canvia el facing (la darrera orientació es preserva), i els girs + // només disparen amb intenció explícita del jugador. + updateFacingAndTurn(delta_time, input_vx_, input_vy_); } void Player::applyMovement(float delta_time) { diff --git a/source/game/entities/player.hpp b/source/game/entities/player.hpp index a450587..3ed3c6f 100644 --- a/source/game/entities/player.hpp +++ b/source/game/entities/player.hpp @@ -84,6 +84,8 @@ class Player : public AnimatedSprite { // GameScene els toca entre `tick` i `applyMovement` per implementar // col·lisions jugador↔jugador (escalar a push_factor, anul·lar en // head-on). Expressades en píxels/segon, no en píxels/frame. + // Amb inèrcia activada, `intended_v*` pot ser no-zero encara que + // l'usuari no estiga prement res (coast residual). [[nodiscard]] auto getIntendedVX() const -> float { return intended_vx_; } [[nodiscard]] auto getIntendedVY() const -> float { return intended_vy_; } void setIntendedVelocity(float vx, float vy) { @@ -91,6 +93,15 @@ class Player : public AnimatedSprite { intended_vy_ = vy; } + // Velocitat crua d'input d'aquest frame (-SPEED, 0 o +SPEED per eix). + // A diferència de `intended_v*`, no té en compte la inèrcia: un jugador + // que coasta sense prémer res té `input_v* == 0`. El resolver PvP del + // GameScene l'usa per decidir "parat vs en moviment" (criteri: un + // coast sense input compta com parat — l'empenta i el head-on només + // es disparen quan hi ha intenció explícita del jugador). + [[nodiscard]] auto getInputVX() const -> float { return input_vx_; } + [[nodiscard]] auto getInputVY() const -> float { return input_vy_; } + // Llista de tots els jugadors del nivell (incloent-se ell mateix — // la callback ignora `this`). La caixa del jugador es considera un // sòlid a la callback de col·lisió: així `tryMove` no permet que un @@ -186,9 +197,26 @@ class Player : public AnimatedSprite { // Velocitat intencionada calculada a `tick` i aplicada a // `applyMovement`. El GameScene pot sobreescriure-la entremig per // resoldre interaccions jugador↔jugador (empentes, head-on). + // També actua com a estat físic persistent entre frames: amb inèrcia + // activada, si l'usuari solta l'input, aquí queda la velocitat + // residual que `tick` anirà decaient linealment cap a 0. float intended_vx_{0.0F}; float intended_vy_{0.0F}; + // Velocitat desitjada per input aquest frame (teclat + gamepad). Cada + // eix és -SPEED, 0 o +SPEED. És la "intenció del jugador"; la física + // resultant viu a `intended_v*` i pot diferir (coast sense input). + float input_vx_{0.0F}; + float input_vy_{0.0F}; + + // Acumulador de temps en moviment continu. Creix cada frame amb + // `|input_v*| > 0` o `|intended_v*| > 0`, i es reseteja a 0 només + // quan NO hi ha ni input ni velocitat residual. Activa la inèrcia + // quan passa el llindar configurat al YAML (`threshold_ms`); no es + // buida mai enmig d'un coast, ni tan sols en canvis de direcció, + // així un toc curt en sentit oposat no "frena en sec" un coast llarg. + float motion_time_ms_{0.0F}; + // Punter a la llista de tots els jugadors del nivell (propietat del // GameScene). `nullptr` = jugador aïllat, sense col·lisió inter-P. const std::vector* all_players_{nullptr}; @@ -223,6 +251,24 @@ class Player : public AnimatedSprite { EAST, WEST }; + // Configuració d'inèrcia llegida del bloc `inertia:` del YAML. Amb + // `brake_time_ms == 0` (o bloc absent) la inèrcia està desactivada i + // el comportament és instantani: soltar input para el jugador en sec. + struct InertiaConfig { + int brake_time_ms{0}; // Temps en ms perquè la velocitat caiga de ±SPEED a 0. + int threshold_ms{0}; // Temps mínim en moviment continu per activar la inèrcia. + }; + static InertiaConfig inertia_config; + + // Integra un eix. Si hi ha input en aquest eix, la velocitat física + // salta a aquest valor (acceleració instantània). Si no, i hi ha + // input en l'altre eix (`any_input == true`), la velocitat d'aquest + // eix es posa a 0 en sec: el jugador està fent un gir de 90°, no + // soltant l'input — no volem que arrossegue en diagonal. Només quan + // l'usuari deixa anar tot l'input (`any_input == false`) s'activa la + // inèrcia de frenada (si `motion_time_ms` ha passat el llindar). + static void integrateAxis(float& phys, float input, float delta_time, float motion_time_ms, bool any_input); + // Configuració del gir de 180°, llegida del bloc `turn:` del YAML del // player. Si `duration_ms` és 0 (o el bloc no existeix) la mecànica // queda desactivada i els canvis de direcció són instantanis. diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index d16ae04..d8acb12 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -92,6 +92,11 @@ namespace { // gestionarà a `applyMovement` mogent la distància parcial possible. // Amb dos jugadors només és un parell; si en un futur n'hi ha més, // la mateixa lògica es pot aplicar pairwise. + // Important: el criteri "mou-se vs parat" consulta INPUT cru (no la + // velocitat física), així un jugador que coasta per inèrcia sense + // input compta com a parat — ni dispara head-on ni empeny l'altre. + // L'overlap predictiu sí que usa velocitat física (intended), perquè + // és el desplaçament real d'aquest frame. void resolvePlayerVsPlayer(const std::vector& ps, float dt, float push_factor) { if (ps.size() < 2) { return; } auto* a = ps[0]; @@ -119,8 +124,8 @@ namespace { }; if (!rectsOverlap(NEXT_A, NEXT_B)) { return; } - const bool MOVING_A = (a->getIntendedVX() != 0.0F) || (a->getIntendedVY() != 0.0F); - const bool MOVING_B = (b->getIntendedVX() != 0.0F) || (b->getIntendedVY() != 0.0F); + const bool MOVING_A = (a->getInputVX() != 0.0F) || (a->getInputVY() != 0.0F); + const bool MOVING_B = (b->getInputVX() != 0.0F) || (b->getInputVY() != 0.0F); if (MOVING_A && MOVING_B) { // Head-on o encreuament amb col·lisió: cap dels dos es mou.