feat(player): inèrcia de frenada amb acumulador de moviment

This commit is contained in:
JailDesigner committed 2026-04-24 20:20:30 +02:00
1 parent 291cbdc957
commit ea0f6d98a9
5 files changed
+153 -7

No files matched your search

+74 -5
View File
@@ -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<int>(); }
if (i.contains("threshold_ms")) { inertia_config.threshold_ms = i["threshold_ms"].get_value<int>(); }
}
// 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<float>(inertia_config.threshold_ms));
if (!INERTIA_ACTIVE) {
phys = 0.0F;
return;
}
const float DECEL = SPEED_PX_PER_S / (static_cast<float>(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) {
+46
View File
@@ -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<Player*>* 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.
+7 -2
View File
@@ -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<Player*>& 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.