Files
aee-arcade/source/game/entities/player.cpp
T

1711 lines
73 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "game/entities/player.hpp"
#include <SDL3/SDL.h>
#include <algorithm>
#include <array>
#include <cmath>
#include <stdexcept>
#include <utility>
#include "core/audio/audio.hpp"
#include "core/input/gamepad_bindings.hpp"
#include "core/input/gamepad_manager.hpp"
#include "core/input/input_state.hpp"
#include "core/rendering/canvas.hpp"
#include "core/rendering/screen.hpp"
#include "core/resources/resource_cache.hpp"
#include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp"
#include "game/entities/entity.hpp"
#include "game/entities/entity_manager.hpp"
#include "game/entities/event_bus.hpp"
#include "game/options.hpp"
#include "utils/defaults.hpp"
namespace {
// Alias local per reduir soroll; la constant viu a Defaults::Player.
constexpr float SPEED_PX_PER_S = Defaults::Player::SPEED_PX_PER_S;
// Dead-zone del stick analògic esquerre (rang Sint16: -32768..32767).
// Converteix eix analògic en direcció digital; per sota, es considera
// repòs i no contribueix a la velocitat.
constexpr Sint16 STICK_DEADZONE = 12000;
// Llegeix la mida del frame des del bloc `sprite: { path, size: [w, h] }`
// (mateix format que la resta de plantilles d'entitat, vegeu
// AnimatedSprite). Extracció per mantenir `Player::loadFrom` sota el
// llindar de complexitat cognitiva (25).
void readSpriteSize(const fkyaml::node& yaml, const std::string& yaml_path, int& w, int& h) {
if (!yaml.contains("sprite") || !yaml["sprite"].contains("size")) {
throw std::runtime_error("Player: manca `sprite.size` a " + yaml_path);
}
const auto& size_node = yaml["sprite"]["size"];
w = size_node[0].get_value<int>();
h = size_node[1].get_value<int>();
}
// Acumula la contribució del mando assignat al jugador (cruceta +
// stick esquerre) sobre els acumuladors de velocitat. Extracció
// d'`update()` per a mantenir la complexitat cognitiva sota llindar
// (threshold 25). L'assignació mando↔jugador viu al `GamepadManager`
// i la persisteix Options; si aquest jugador no en té cap assignat,
// la funció és un no-op (sols es mou amb el teclat).
void accumulateGamepadMovement(int player_id, float& vx, float& vy) {
SDL_Gamepad* pad = GamepadManager::get().getForPlayer(player_id);
if (pad == nullptr) { return; }
if (InputState::getGamepadButton(pad, SDL_GAMEPAD_BUTTON_DPAD_LEFT)) { vx -= SPEED_PX_PER_S; }
if (InputState::getGamepadButton(pad, SDL_GAMEPAD_BUTTON_DPAD_RIGHT)) { vx += SPEED_PX_PER_S; }
if (InputState::getGamepadButton(pad, SDL_GAMEPAD_BUTTON_DPAD_UP)) { vy -= SPEED_PX_PER_S; }
if (InputState::getGamepadButton(pad, SDL_GAMEPAD_BUTTON_DPAD_DOWN)) { vy += SPEED_PX_PER_S; }
const Sint16 LX = InputState::getGamepadAxis(pad, SDL_GAMEPAD_AXIS_LEFTX);
const Sint16 LY = InputState::getGamepadAxis(pad, SDL_GAMEPAD_AXIS_LEFTY);
if (LX < -STICK_DEADZONE) { vx -= SPEED_PX_PER_S; }
if (LX > STICK_DEADZONE) { vx += SPEED_PX_PER_S; }
if (LY < -STICK_DEADZONE) { vy -= SPEED_PX_PER_S; }
if (LY > STICK_DEADZONE) { vy += SPEED_PX_PER_S; }
}
} // namespace
Player::TurnConfig Player::turn_config;
Player::InertiaConfig Player::inertia_config;
Player::JumpConfig Player::jump_config;
Player::HurtConfig Player::hurt_config;
Player::WhipConfig Player::whip_config;
Player::DeathConfig Player::death_config;
void Player::loadFrom(const std::string& yaml_path) {
auto bytes = Resource::Helper::loadFile(yaml_path);
if (bytes.empty()) {
throw std::runtime_error("Player: no s'ha pogut obrir " + yaml_path);
}
const auto* begin = reinterpret_cast<const char*>(bytes.data());
const auto* end = begin + bytes.size();
auto yaml = fkyaml::node::deserialize(begin, end);
readSpriteSize(yaml, yaml_path, sprite_w, sprite_h);
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).
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{};
if (yaml.contains("turn")) {
const auto& t = yaml["turn"];
if (t.contains("duration_ms")) { turn_config.duration_ms = t["duration_ms"].get_value<int>(); }
if (t.contains("transitions")) {
const auto& tr = t["transitions"];
if (tr.contains("west_to_east")) { turn_config.w_to_e = cardinalFromString(tr["west_to_east"].get_value<std::string>()); }
if (tr.contains("east_to_west")) { turn_config.e_to_w = cardinalFromString(tr["east_to_west"].get_value<std::string>()); }
if (tr.contains("north_to_south")) { turn_config.n_to_s = cardinalFromString(tr["north_to_south"].get_value<std::string>()); }
if (tr.contains("south_to_north")) { turn_config.s_to_n = cardinalFromString(tr["south_to_north"].get_value<std::string>()); }
}
}
// 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);
// Bloc opcional `hurt:` — estat post-impacte. Si manca o el speed
// és 0, l'estat HURT mai s'activa (cas degenerat).
readHurtBlock(yaml);
// Bloc opcional `whip:` — paràmetres del látigo. Si manca, el
// jugador no atacarà efectivament (ATTACKING dura 0 ms i el flanc
// d'input es perd).
readWhipBlock(yaml);
// Bloc opcional `death:` — pausa de respawn i comptador inicial de
// vides. Si manca, el respawn és immediat i les vides queden a 3
// per defecte (no és un valor crític: avui no hi ha game over).
readDeathBlock(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<int>();
base_size = c["base_size"].get_value<int>();
// 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<int>() : 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<int>() : 0;
}
void Player::readJumpBlock(const fkyaml::node& yaml) {
jump_config = JumpConfig{};
if (!yaml.contains("jump")) { return; }
const auto& j = yaml["jump"];
if (j.contains("impulse")) { jump_config.impulse_px_per_s = j["impulse"].get_value<float>(); }
if (j.contains("gravity")) { jump_config.gravity_px_per_s2 = j["gravity"].get_value<float>(); }
if (j.contains("landing_stun_ms")) { jump_config.landing_stun_ms = j["landing_stun_ms"].get_value<int>(); }
if (j.contains("shadow_y_offset")) { jump_config.shadow_y_offset = j["shadow_y_offset"].get_value<int>(); }
// 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<std::string>();
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<std::string>();
Resource::Cache::get().loadSound(jump_config.land_sound_key, "data/sounds/" + jump_config.land_sound_key);
}
}
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<float>(); }
if (h.contains("brake_time_ms")) { hurt_config.brake_time_ms = h["brake_time_ms"].get_value<int>(); }
// So d'entrada a HURT — mateix patró que `jump.sound` i `whip.sound`.
if (h.contains("sound")) {
hurt_config.sound_key = h["sound"].get_value<std::string>();
Resource::Cache::get().loadSound(hurt_config.sound_key, "data/sounds/" + hurt_config.sound_key);
}
}
void Player::readDeathBlock(const fkyaml::node& yaml) {
death_config = DeathConfig{};
if (!yaml.contains("death")) { return; }
const auto& d = yaml["death"];
if (d.contains("respawn_delay_ms")) { death_config.respawn_delay_ms = d["respawn_delay_ms"].get_value<int>(); }
if (d.contains("lives")) { death_config.initial_lives = d["lives"].get_value<int>(); }
}
void Player::readWhipBlock(const fkyaml::node& yaml) {
whip_config = WhipConfig{};
if (!yaml.contains("whip")) { return; }
const auto& w = yaml["whip"];
if (w.contains("range_tiles")) { whip_config.range_tiles = w["range_tiles"].get_value<float>(); }
if (w.contains("swing_ms")) { whip_config.swing_ms = w["swing_ms"].get_value<int>(); }
if (w.contains("cooldown_ms")) { whip_config.cooldown_ms = w["cooldown_ms"].get_value<int>(); }
if (w.contains("damage")) { whip_config.damage = w["damage"].get_value<int>(); }
// So del swing: mateix patró que `jump.sound` — basename val de
// clau al cache i de path. Si manca, queda buit i no es dispara.
if (w.contains("sound")) {
whip_config.sound_key = w["sound"].get_value<std::string>();
Resource::Cache::get().loadSound(whip_config.sound_key, "data/sounds/" + whip_config.sound_key);
}
}
void Player::applyCardinal(Cardinal c) {
// El flip del sprite el governa l'animació al motor: només cal posar
// el `facing_` que tocaria; `animationName()` ja resol el sufix
// (`_west` per Cardinal::WEST) i el YAML hi declara `flip_h: true`.
switch (c) {
case Cardinal::NORTH:
facing_ = Facing::NORTH;
break;
case Cardinal::SOUTH:
facing_ = Facing::SOUTH;
break;
case Cardinal::EAST:
facing_ = Facing::EAST;
break;
case Cardinal::WEST:
facing_ = Facing::WEST;
break;
case Cardinal::NONE:
break;
}
}
auto Player::cardinalFromInput(float vx, float vy) -> Cardinal {
// Mateixa precedència que la lògica original: l'horitzontal preval
// sobre el vertical (els diagonals s'interpreten com a laterals).
if (vx > 0.0F) { return Cardinal::EAST; }
if (vx < 0.0F) { return Cardinal::WEST; }
if (vy < 0.0F) { return Cardinal::NORTH; }
if (vy > 0.0F) { return Cardinal::SOUTH; }
return Cardinal::NONE;
}
auto Player::cardinalFromString(const std::string& s) -> Cardinal {
if (s == "north") { return Cardinal::NORTH; }
if (s == "south") { return Cardinal::SOUTH; }
if (s == "east") { return Cardinal::EAST; }
if (s == "west") { return Cardinal::WEST; }
throw std::runtime_error("Player: cardinal desconegut '" + s + "' al bloc turn.transitions");
}
auto Player::opposite(Cardinal c) -> Cardinal {
switch (c) {
case Cardinal::NORTH:
return Cardinal::SOUTH;
case Cardinal::SOUTH:
return Cardinal::NORTH;
case Cardinal::EAST:
return Cardinal::WEST;
case Cardinal::WEST:
return Cardinal::EAST;
case Cardinal::NONE:
return Cardinal::NONE;
}
return Cardinal::NONE;
}
auto Player::intermediateFor(Cardinal target) -> Cardinal {
switch (target) {
case Cardinal::EAST:
return turn_config.w_to_e;
case Cardinal::WEST:
return turn_config.e_to_w;
case Cardinal::SOUTH:
return turn_config.n_to_s;
case Cardinal::NORTH:
return turn_config.s_to_n;
case Cardinal::NONE:
return Cardinal::NONE;
}
return Cardinal::NONE;
}
void Player::updateFacingAndTurn(float delta_time, float vx, float vy) {
// Cardinal d'INPUT cru. Important: la detecció del gir de 180° s'ha
// de basar en l'input (intent del jugador), no en la velocitat
// resultant. Mentre no hi haja inèrcia, vx/vy són directament l'input
// i serveixen com a tal; quan s'introduïsca, cal preservar aquesta
// separació (calcular el cardinal a partir de les tecles/gamepad
// crus, no de la velocitat suavitzada).
const Cardinal INPUT_CARD = cardinalFromInput(vx, vy);
// Si hi ha un gir en curs, descomptem el timer i mantenim el facing
// forçat al cardinal intermedi. Quan s'esgota, apliquem el cardinal
// pendent. El moviment físic continua sense alterar-se (vx/vy ja
// s'han enviat a setVel al caller).
if (turning_remaining_ms_ > 0.0F) {
turning_remaining_ms_ -= delta_time * 1000.0F;
if (turning_remaining_ms_ <= 0.0F) {
turning_remaining_ms_ = 0.0F;
applyCardinal(pending_cardinal_);
pending_cardinal_ = Cardinal::NONE;
intermediate_cardinal_ = Cardinal::NONE;
} else {
applyCardinal(intermediate_cardinal_);
}
} else {
// Detecció del gir: `last_input_cardinal_` és l'últim cardinal
// d'input no nul (es preserva entre releases). Així, el primer
// input des de l'estat idle no dispara cap gir — només dispara
// quan el jugador "canvia d'opinió" prement la direcció oposada
// a la prèvia. Requereix també que el bloc `turn:` del YAML
// estiga activat (`duration_ms > 0`).
const bool TRIGGER_TURN =
(turn_config.duration_ms > 0) && INPUT_CARD != Cardinal::NONE && last_input_cardinal_ != Cardinal::NONE && INPUT_CARD == opposite(last_input_cardinal_);
if (TRIGGER_TURN) {
pending_cardinal_ = INPUT_CARD;
intermediate_cardinal_ = intermediateFor(INPUT_CARD);
turning_remaining_ms_ = static_cast<float>(turn_config.duration_ms);
applyCardinal(intermediate_cardinal_);
} else if (vx != 0.0F) {
// Tria de facing: prevalença lateral, EAST/WEST segons el signe
// de vx; verticals purs van a NORTH/SOUTH. En aturar-se
// conservem `facing_` per a l'animació stand.
facing_ = (vx < 0.0F) ? Facing::WEST : Facing::EAST;
} else if (vy < 0.0F) {
facing_ = Facing::NORTH;
} else if (vy > 0.0F) {
facing_ = Facing::SOUTH;
}
}
// Memoritzem l'últim cardinal d'input no nul per a la propera detecció
// de gir (ignorant els zeros: l'estat idle no reinicia la memòria).
if (INPUT_CARD != Cardinal::NONE) {
last_input_cardinal_ = INPUT_CARD;
}
}
Player::Player(const std::string& anim_key, int id, float x, float y, const Controls& controls, const Map::Info& map_info, int paint_tile_index)
: AnimatedSprite(Resource::Cache::get().getAnimationData(anim_key)),
id_(id),
controls_(controls),
map_(map_info),
paint_tile_index_(paint_tile_index) {
setPos(x, y);
// Postura inicial mirant cap al jugador (cara avall), no de costat:
// forcem facing_ a SOUTH i l'animació `stand_south` abans del primer
// frame d'input — el primer `update` la preservarà si l'usuari no
// prem cap tecla.
facing_ = Facing::SOUTH;
setCurrentAnimation("stand_south");
// Comptador d'intents inicialitzat amb el valor del YAML (default 3
// si el bloc `death:` manca). Fora del cicle DYING no toca a ningú.
lives_remaining_ = death_config.initial_lives;
// Spawn point inicial: la posició del ctor val per defecte; el
// GameScene el reescriu via `setSpawnPoint` quan canvia de sala.
spawn_x_ = x;
spawn_y_ = y;
}
void Player::update(float delta_time) {
tick(delta_time);
applyMovement(delta_time);
}
auto Player::collidesWithWorld(const SDL_FRect& box) const -> bool {
// EXITED/DISABLED: jugador fora del joc. No xoca amb res ni res
// xoca amb ell. També es filtren els altres jugadors fora de joc
// al check actor↔actor més avall via el predicat `isOutOfPlay`.
if (isOutOfPlay()) { return false; }
if (map_.collides(box)) { return true; }
if (all_players_ == nullptr) { return false; }
// Per al contacte actor-actor usem la hit-box petita a banda i banda:
// projectem la hit-box des de la posició base proposada i la comparem
// 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.
//
// Regla de col·lisió jugador↔jugador (segons el feel triat):
// - 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.
// - 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 bool MY_AIR = (state_ == State::AIRBORNE);
const bool MY_HURT = (state_ == State::HURT);
const float MY_Z = jump_z_;
const bool MY_DYING = (state_ == State::DYING);
return std::ranges::any_of(*all_players_, [this, &PROPOSED_HIT, MY_AIR, MY_HURT, MY_DYING, MY_Z](const Player* p) {
if (p == nullptr || p == this) { return false; }
if (p->isOutOfPlay()) { return false; }
// DYING és intangible per a PvP: el jugador mort no actua de
// mur, i un altre jugador en DYING no pot empentar tampoc.
if (MY_DYING || p->isDying()) { 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,
// 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<float>(body_height)) {
return false;
}
const auto OTHER = p->getHitBox();
return SDL_HasRectIntersectionFloat(&PROPOSED_HIT, &OTHER);
});
}
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);
}
}
// Pipeline d'estats i input separats; extracció a helpers empitjoraria la
// llegibilitat. La complexitat puja 1 per la guarda EXITED.
// NOLINTNEXTLINE(readability-function-cognitive-complexity)
void Player::tick(float delta_time) {
// EXITED/DISABLED: jugador fora del joc, no fa res. Cap input, cap
// física, cap bus event. Continua sent al vector de jugadors per
// simplicitat (no s'esborra) però queda completament inert fins
// que GameScene el reactiva (respawn / spawnFromAir).
if (isOutOfPlay()) { return; }
// DYING: animació `death_spinning` + pausa de respawn. Ignorem tot
// l'input (ni moviment, ni salt, ni atac). El moviment queda
// congelat al lloc de la mort; `applyMovement` avança l'animació i
// detecta la fi per disparar el respawn. Mantenim els flancs de
// botó actualitzats perquè una tecla sostinguda no es comporte com
// a flanc nou en sortir del cicle.
if (state_ == State::DYING) {
(void)consumeJumpEdge();
(void)consumeAttackEdge();
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);
return;
}
// 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
// (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();
(void)consumeAttackEdge();
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();
(void)consumeAttackEdge();
setVelX(intended_vx_);
setVelY(intended_vy_);
return;
}
// En ATTACKING el jugador queda bloquejat tot el `swing_ms +
// cooldown_ms`: ni input direccional, ni salt, ni atac, ni canvi
// de facing. El timer descompta amb `delta_time * 1000` per
// mantenir la convenció de delta_ms de la resta d'estats temporals.
// Mantenim els flancs dels botons actualitzats perquè una
// tecla mantinguda no es comporte com a flanc nou en sortir.
if (state_ == State::ATTACKING) {
(void)consumeJumpEdge();
(void)consumeAttackEdge();
attacking_remaining_ms_ -= delta_time * 1000.0F;
if (attacking_remaining_ms_ <= 0.0F) {
attacking_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);
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();
(void)consumeAttackEdge();
const float DECEL = (hurt_config.brake_time_ms > 0)
? hurt_config.initial_speed_px_per_s / (static_cast<float>(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;
}
// Refresca les vinculacions des d'Options en calent: així un remap fet
// al menú de servei té efecte immediatament, sense esperar al pròxim
// GameScene::onEnter. Replica el comportament d'AEE
// (`KeyRemap::update` que llig `Options::keys_game` cada frame).
refreshControlsFromOptions();
// 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
// navega el menú. Polling-blocked; veure `core/input/input_state.hpp`.
const bool* keys = InputState::getKeyboardState();
float vx = 0.0F;
float vy = 0.0F;
if (keys[controls_.left]) { vx -= SPEED_PX_PER_S; }
if (keys[controls_.right]) { vx += SPEED_PX_PER_S; }
if (keys[controls_.up]) { vy -= SPEED_PX_PER_S; }
if (keys[controls_.down]) { vy += SPEED_PX_PER_S; }
// Cruceta i stick esquerre del mando assignat (si n'hi ha). També
// respecten el bloqueig d'`InputState`, així que amb el menú obert
// els botons tornen false/0 i no contribueixen a la velocitat.
accumulateGamepadMovement(id_, vx, vy);
input_vx_ = vx;
input_vy_ = 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_);
// Detecció de flanc del botó d'atac. Es processa ABANS del salt
// perquè ATTACKING i AIRBORNE són mútuament excloents i, davant un
// mateix-frame, l'atac té prioritat (millor castigar la pulsació
// doble per excés que perdre l'atac per un toc accidental al salt).
// En entrar a ATTACKING, descartem el flanc del salt per no que
// queda penjat per a quan es desbloque.
if (consumeAttackEdge() && whip_config.swing_ms > 0) {
enterAttacking(currentFacingCardinal());
(void)consumeJumpEdge();
return;
}
// 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();
}
}
void Player::refreshControlsFromOptions() {
if (id_ != 0 && id_ != 1) { return; }
const auto& kb = (id_ == 0) ? Options::controls().keyboard_p1
: Options::controls().keyboard_p2;
controls_.up = kb.up;
controls_.down = kb.down;
controls_.left = kb.left;
controls_.right = kb.right;
controls_.jump = kb.jump;
controls_.attack = kb.attack;
}
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 auto& slot = (id_ == 0) ? Options::controls().gamepad_p1
: Options::controls().gamepad_p2;
const int JUMP_BTN = GamepadBindings::get().getButton(slot.name, slot.path, GamepadBindings::Action::JUMP);
const bool PAD_NOW = (pad != nullptr) && !InputState::isBlocked() &&
GamepadBindings::isPressed(pad, JUMP_BTN);
if (PAD_NOW && !jump_held_gamepad_) { edge = true; }
jump_held_gamepad_ = PAD_NOW;
return edge;
}
auto Player::consumeAttackEdge() -> bool {
// Mateix patró que `consumeJumpEdge` però per al botó d'atac. El botó
// físic ve d'`GamepadBindings`, persistit per dispositiu — l'usuari
// l'ha pogut remapejar des del menú de servei.
const bool* keys = InputState::getKeyboardState();
const bool KEY_NOW = keys[controls_.attack];
bool edge = (KEY_NOW && !attack_held_keyboard_);
attack_held_keyboard_ = KEY_NOW;
SDL_Gamepad* pad = GamepadManager::get().getForPlayer(id_);
const auto& slot = (id_ == 0) ? Options::controls().gamepad_p1
: Options::controls().gamepad_p2;
const int ATK_BTN = GamepadBindings::get().getButton(slot.name, slot.path, GamepadBindings::Action::ATTACK);
const bool PAD_NOW = (pad != nullptr) && !InputState::isBlocked() &&
GamepadBindings::isPressed(pad, ATK_BTN);
if (PAD_NOW && !attack_held_gamepad_) { edge = true; }
attack_held_gamepad_ = PAD_NOW;
return edge;
}
auto Player::currentFacingCardinal() const -> Cardinal {
switch (facing_) {
case Facing::NORTH:
return Cardinal::NORTH;
case Facing::SOUTH:
return Cardinal::SOUTH;
case Facing::EAST:
return Cardinal::EAST;
case Facing::WEST:
return Cardinal::WEST;
}
return Cardinal::EAST;
}
void Player::enterAttacking(Cardinal facing) {
state_ = State::ATTACKING;
attacking_remaining_ms_ = static_cast<float>(whip_config.swing_ms + whip_config.cooldown_ms);
// Garantim que facing/flip i cardinal queden coherents amb la
// direcció triada (per si l'atac s'ha disparat amb facing residual,
// p.ex. just després d'un canvi d'input).
applyCardinal(facing);
if (facing == Cardinal::NONE) { return; }
// So del swing — disparat ací (no des del WhipAttack) perquè és el
// jugador qui executa l'acció; si demà el spawn falla per qualsevol
// motiu, el so del gest segueix sonant.
if (!whip_config.sound_key.empty() && Audio::get() != nullptr) {
Audio::get()->playSound(whip_config.sound_key, Audio::Group::GAME);
}
// Mapa de Cardinal → int al payload (vegeu `WhipSpawnPayload`).
int dir_int = 0;
switch (facing) {
case Cardinal::NORTH:
dir_int = 1;
break;
case Cardinal::SOUTH:
dir_int = 2;
break;
case Cardinal::EAST:
dir_int = 3;
break;
case Cardinal::WEST:
dir_int = 4;
break;
case Cardinal::NONE:
return;
}
// Passem el pixel_rect (16×32 a sam/morcus) al payload; el WhipAttack
// en deriva tant la draw_rect (sprite del látigo, més gran) com la
// damage_rect (al nivell dels peus, retallada per bloquejadors).
if (bus_ != nullptr) {
const float RANGE_PX = whip_config.range_tiles * static_cast<float>(Map::tile_size);
bus_->publish("whip_spawn", WhipSpawnPayload{
.owner = this,
.direction = dir_int,
.owner_box = getPosition(),
.range_px = RANGE_PX,
});
}
}
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; }
// 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_south). 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;
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<float>(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.
}
auto Player::deltasFor(Cardinal c) -> std::pair<float, float> {
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<float>(i) * dx;
probe.y += static_cast<float>(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<int>(
0.5F * hurt_config.initial_speed_px_per_s * (static_cast<float>(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<Cardinal, 4> 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; }
const Cardinal CHOSEN = chooseHurtDirection(preferred);
// Activem l'estat i fixem la velocitat segons la direcció triada.
state_ = State::HURT;
if (!hurt_config.sound_key.empty() && Audio::get() != nullptr) {
Audio::get()->playSound(hurt_config.sound_key, Audio::Group::GAME);
}
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;
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;
}
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;
// Caiguda per agujero: el sòl ja no és present sota el jugador, així
// que NO clampem `jump_z_` a 0. Deixem baixar a negatius (visualment
// el sprite "s'enfonsa" sota el sòl) i, en superar `HOLE_FALL_DEPTH`,
// disparem la mort. La cinemàtica de respawn (`enterDying →
// spawnFromAir`) reseteja el flag i la posició al spawn point.
if (falling_through_hole_) {
if (jump_z_ <= -HOLE_FALL_DEPTH) {
enterDying(DeathCause::HOLE);
}
return false;
}
if (jump_z_ <= 0.0F) {
// Aterratge sobre decal-trigger (Hole, futurs pinchos plans...):
// saltem el LANDING_STUN i transicionem directament a la caiguda
// pel forat. Sense aquesta detecció, l'aterratge entraria a
// LANDING_STUN (~200 ms flotant sobre el forat) i només quan
// tornés a GROUNDED `Hole::onPlayerContact` ho dispararia. La
// hit-box (8×8 centrada al sprite) és la mateixa que `Hole`
// usa al seu re-check; coherent. Si el manager no s'ha
// injectat (tests stand-alone), el camí d'aterratge segueix sent
// el habitual.
if (entity_manager_ != nullptr) {
const Entity* trigger = entity_manager_->findFloorTriggerAt(getHitBox());
if (trigger != nullptr) {
const auto BOX = trigger->getCollisionRect();
const float CENTER_X = BOX.x + (BOX.w * 0.5F);
const float CENTER_Y = BOX.y + (BOX.h * 0.5F);
const float FLOOR_Y = BOX.y + BOX.h;
enterHoleFall(CENTER_X, CENTER_Y, FLOOR_Y);
// No retornem `true`: no és un aterratge "normal" i
// `applyMovement` no ha de cridar `resolveLandingOverlap`.
return false;
}
}
jump_z_ = 0.0F;
jump_vz_ = 0.0F;
state_ = State::LANDING_STUN;
landing_stun_remaining_ms_ = static_cast<float>(jump_config.landing_stun_ms);
if (!jump_config.land_sound_key.empty() && Audio::get() != nullptr) {
// Consumeix el preset armat per GameScene al spawn aeri
// d'INTRO (single-shot): el primer LAND el sona reverberat,
// els salts següents cauen al camí sec.
if (next_land_reverb_preset_.empty()) {
Audio::get()->playSound(jump_config.land_sound_key, Audio::Group::GAME);
} else {
Audio::get()->playSoundWithReverb(jump_config.land_sound_key, next_land_reverb_preset_, Audio::Group::GAME);
next_land_reverb_preset_.clear();
}
}
if (bus_ != nullptr) {
bus_->publish(Event{
.name = "player_landed",
.payload = WorldPos{.x = getPosX() + (static_cast<float>(sprite_w) * 0.5F), .y = getGroundFeetY()},
.player_id = id_,
});
}
return true;
}
return false;
}
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; }
// Salta jugadors fora del joc: un caigut sobre un slot DISABLED
// (o sobre un EXITED encara al vector) no els ha de projectar.
// També DYING — un jugador en cicle de mort no rep cap més
// efecte fins que respawneja.
if (other->isOutOfPlay() || other->isDying()) { 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; }
// 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);
}
}
void Player::applyMovement(float delta_time) {
// EXITED/DISABLED: cap moviment ni col·lisió. Idèntic al gating de `tick`.
if (isOutOfPlay()) { return; }
// 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 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();
}
if (state_ == State::DYING) {
applyDyingMovement(delta_time);
return;
}
if (state_ == State::HURT) {
applyHurtMovement(delta_time);
return;
}
if (state_ == State::LANDING_STUN) {
applyLandingStunMovement(delta_time);
return;
}
if (state_ == State::ATTACKING) {
// Cap moviment durant ATTACKING. Animem (perquè avance la
// seqüència `attack_*`) i actualitzem la clau d'animació segons
// la sub-fase (swing vs cooldown).
animate(delta_time);
setCurrentAnimation(animationName(false));
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.
const float VX = intended_vx_;
const float VY = intended_vy_;
// 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. 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();
if (ONLY_H) {
snapToHorizontalLane(delta_time);
} else if (ONLY_V) {
snapToVerticalLane(delta_time);
}
// Animació del frame + moviment col·lisió-aware. `tryMove` aplica els
// deltes per eix i reverteix l'eix que col·lisiona; així el jugador
// llisca contra sarcòfags, murs i altres jugadors en compte de quedar
// bloquejat en sec. La callback tracta els altres jugadors com a
// sòlids per evitar overlaps (el resolver del GameScene deixa les
// velocitats "segures", i aquesta comprovació és la xarxa de
// seguretat davant de casos d'ordre d'aplicació).
animate(delta_time);
tryMove(VX * delta_time, VY * delta_time, [this](const SDL_FRect& box) {
return collidesWithWorld(box);
});
clampToPlayfield();
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. 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));
// 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::enterDying(DeathCause cause) {
// Idempotent. Un segon contacte de momia mentre el jugador ja està
// en DYING no reinicia l'animació ni el comptador. Tampoc disparem
// el cicle si el jugador està fora de joc (EXITED al passar la
// porta, DISABLED durant les transicions de sala).
if (state_ == State::DYING || isOutOfPlay()) { return; }
state_ = State::DYING;
intended_vx_ = 0.0F;
intended_vy_ = 0.0F;
input_vx_ = 0.0F;
input_vy_ = 0.0F;
motion_time_ms_ = 0.0F;
being_pushed_ = false;
jump_z_ = 0.0F;
jump_vz_ = 0.0F;
falling_through_hole_ = false;
hole_clip_bottom_y_ = 0.0F;
landing_stun_remaining_ms_ = 0.0F;
attacking_remaining_ms_ = 0.0F;
turning_remaining_ms_ = 0.0F;
pending_cardinal_ = Cardinal::NONE;
intermediate_cardinal_ = Cardinal::NONE;
last_input_cardinal_ = Cardinal::NONE;
setVelX(0.0F);
setVelY(0.0F);
death_animation_complete_ = false;
respawn_remaining_ms_ = 0.0F;
// Presentació segons la causa. Cada `DeathCause` té la seua animació
// dedicada al YAML del player; `applyDyingMovement` arma el respawn
// timer quan `animationIsCompleted()` flanqueja, igual per a totes.
switch (cause) {
case DeathCause::HOLE:
setCurrentAnimation("death_hole");
break;
case DeathCause::GENERIC:
default:
setCurrentAnimation("death_spinning");
break;
}
// So del cop letal: reaprofitem `hurt.wav` (ja precarregat per
// `readHurtBlock`). El bloc `death:` no té camp de so propi —
// semànticament l'impacte mortal és la versió forta de `hurt`.
if (!hurt_config.sound_key.empty() && Audio::get() != nullptr) {
Audio::get()->playSound(hurt_config.sound_key, Audio::Group::GAME);
}
}
void Player::applyDyingMovement(float delta_time) {
// Avancem l'animació; el sprite no es mou (la posició queda
// congelada al punt de la mort). `animationIsCompleted()` flanqueja
// un cop la roda arriba al darrer frame (loop:false). Llavors
// descomptem `respawn_delay_ms` mantenint el sprite al darrer frame
// i, en arribar a 0, disparem el respawn.
animate(delta_time);
if (!death_animation_complete_ && animationIsCompleted()) {
death_animation_complete_ = true;
respawn_remaining_ms_ = static_cast<float>(death_config.respawn_delay_ms);
}
if (death_animation_complete_) {
respawn_remaining_ms_ -= delta_time * 1000.0F;
if (respawn_remaining_ms_ <= 0.0F) {
respawnFromDying();
}
}
}
void Player::respawnFromDying() {
// Vides: decreix sempre. Pot anar a negatius — no hi ha game over.
--lives_remaining_;
death_animation_complete_ = false;
respawn_remaining_ms_ = 0.0F;
// Cinemàtica idèntica a la d'INTRO de sala: el jugador cau des de
// dalt fins al passadís d'spawn (`spawnFromAir` reseteja primer i
// després posa AIRBORNE amb l'altura derivada de la geometria).
// Així el respawn post-mort comparteix tota la sensació visual i
// física del primer spawn — incloent el LANDING_STUN final.
spawnFromAir(spawn_x_, spawn_y_);
}
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() {
// EXITED/DISABLED: invisible. La crida al render base es salta
// sencera; el jugador no apareix al z-sort tot i seguir vivint al
// vector.
if (isOutOfPlay()) { return; }
// Limbo post-mort: durant `respawn_delay_ms` entre la fi de
// `death_spinning` i el `spawnFromAir`, el jugador queda invisible.
// Així el cicle complet és: animació rodant → desapareix → ressuscita
// caient des de dalt. Sense aquest gat, el sprite quedaria pintat al
// darrer frame de la roda mentre passa el delay.
if (state_ == State::DYING && death_animation_complete_) { return; }
// Mentre el jugador està enlaire, el sprite es desplaça visualment
// `jump_z_` píxels: cap amunt si és positiu (salt), cap avall si és
// negatiu (caiguda per `Hole`, que deixa baixar `jump_z_` a negatius
// fins a `-HOLE_FALL_DEPTH`). 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. El cas
// `jump_z_ == 0` (a terra) surt per l'early-return per evitar tocar
// `pos_.y` innecessàriament.
// Caiguda per agujero: el sprite es "menja" pel sòl. Fixem un clip
// global a la franja [0, hole_clip_bottom_y_] del canvas (el blitter
// de `Surface` respecta `Rendering::activeClip()`); a mesura que
// `pos_.y - jump_z_` baixa, els píxels del sprite que creuen la
// línia deixen de pintar-se. Fem el push/pop al voltant de tot el
// render del sprite per cobrir tant el cas `jump_z_==0` (caiguda
// just iniciada) com els frames posteriors. `pushClip` ja clamp
// a la mida del canvas, així que un `h` negatiu es traduiria en
// una franja buida i el sprite quedaria invisible — comportament
// correcte si el clip ja ha "tapat" tota la pantalla.
const bool CLIP_HOLE = falling_through_hole_;
if (CLIP_HOLE) {
const auto CANVAS = Rendering::activeCanvas();
Rendering::pushClip({
.x = 0,
.y = 0,
.w = CANVAS.width,
.h = static_cast<int>(hole_clip_bottom_y_),
});
}
if (jump_z_ == 0.0F) {
AnimatedSprite::render();
} else {
const float SAVED_Y = pos_.y;
pos_.y = SAVED_Y - jump_z_;
AnimatedSprite::render();
pos_.y = SAVED_Y;
}
if (CLIP_HOLE) { Rendering::popClip(); }
}
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; }
// Caient per un agujero: el cos s'enfonsa sota el sòl, dibuixar una
// sombra al ras del sòl trenca la il·lusió. Suprimim la sombra fins
// que `enterDying` netegi el flag.
if (falling_through_hole_) { 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<int>(static_cast<float>(base_size) * SHRINK);
const float CX = getPosX() + (static_cast<float>(sprite_w) * 0.5F);
const float GROUND_Y = getGroundFeetY();
const auto SX = static_cast<int>(CX - (static_cast<float>(SHADOW_W) * 0.5F));
// 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<int>(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.
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);
}
// Sufix d'animació per cardinal — empat 1:1 amb l'enum `Facing` i el
// sufix declarat als YAMLs de jugador (sam.yaml, morcus.yaml).
auto Player::facingSuffix(Facing facing) -> const char* {
switch (facing) {
case Facing::NORTH:
return "_north";
case Facing::SOUTH:
return "_south";
case Facing::EAST:
return "_east";
case Facing::WEST:
return "_west";
}
return "_east";
}
// Prefix d'animació per estat. ATTACKING té dues sub-fases: durant
// `swing_ms` és `attack_*` (animació del braç llançant el látigo); durant
// `cooldown_ms` posterior, `recover_*`. A AIRBORNE/LANDING_STUN/HURT, la
// variant és per direcció, no per velocitat: l'estat bloqueja el `moving`
// argument i no fa servir walk/stand.
auto Player::statePrefix(State state, bool moving, bool in_attack_swing) -> const char* {
switch (state) {
case State::AIRBORNE:
return "jump";
case State::LANDING_STUN:
return "stun";
case State::HURT:
return "hurt";
case State::ATTACKING:
return in_attack_swing ? "attack" : "recover";
case State::DYING:
// L'animació concreta (`death_spinning` o `death_hole`) la
// selecciona `enterDying` segons `DeathCause`; aquest retorn
// només cobreix exhaustivitat del switch i no s'usa al runtime.
return "death";
case State::GROUNDED:
case State::EXITED:
case State::DISABLED:
break;
}
return moving ? "walk" : "stand";
}
auto Player::animationName(bool moving) const -> std::string {
const bool IN_ATTACK_SWING = (attacking_remaining_ms_ > static_cast<float>(whip_config.cooldown_ms));
return std::string(statePrefix(state_, moving, IN_ATTACK_SWING)) + facingSuffix(facing_);
}
auto Player::getCurrentTile() const -> std::pair<int, int> {
// El grid del tilemap és de `tile_size` píxels i està 16-alineat
// respecte a l'origen del tilemap (pot ser negatiu per overscan). El
// centre de la caixa de col·lisió ens dóna el tile on "està" el Player.
const auto BOX = baseBox();
const float CX = BOX.x + (BOX.w * 0.5F);
const float CY = BOX.y + (BOX.h * 0.5F);
const int COL = static_cast<int>(std::floor(
(CX - static_cast<float>(map_.tilemap_origin_x)) / static_cast<float>(map_.tile_size)));
const int ROW = static_cast<int>(std::floor(
(CY - static_cast<float>(map_.tilemap_origin_y)) / static_cast<float>(map_.tile_size)));
return {COL, ROW};
}
void Player::clampToPlayfield() {
// Límits de la zona jugable: la caixa de col·lisió no pot eixir del
// playfield (muros). El cap del sprite sí pot sobresortir per damunt.
const auto MIN_X = static_cast<float>(map_.playfield_x - collision_x_offset);
const auto MAX_X = static_cast<float>(
map_.playfield_x + map_.playfield_w - collision_x_offset - collision_size);
auto min_y = static_cast<float>(map_.playfield_y - collision_y_offset);
const auto MAX_Y = static_cast<float>(
map_.playfield_y + map_.playfield_h - collision_y_offset - collision_size);
// Passadís de sortida: si la base box queda enterament dins del rang
// X del passadís de la porta, deixem que pugi al strip del mur trasser
// (fins a `door_corridor.y`). Fora del rang, MIN_Y es queda al
// playfield. La regla "enterament dins" exigeix alinear-se al passadís
// abans de pujar — la base box (12 px) hi cap amb 4 px de marge.
if (map_.door_corridor_w > 0) {
const float BASE_X = getPosX() + static_cast<float>(collision_x_offset);
const float BASE_RIGHT = BASE_X + static_cast<float>(collision_size);
const auto CORRIDOR_LEFT = static_cast<float>(map_.door_corridor_x);
const auto CORRIDOR_RIGHT = static_cast<float>(map_.door_corridor_x + map_.door_corridor_w);
if (BASE_X >= CORRIDOR_LEFT && BASE_RIGHT <= CORRIDOR_RIGHT) {
min_y = static_cast<float>(map_.door_corridor_y - collision_y_offset);
}
}
setPosX(std::clamp(getPosX(), MIN_X, MAX_X));
setPosY(std::clamp(getPosY(), min_y, MAX_Y));
}
void Player::respawn(float x, float y) {
state_ = State::GROUNDED;
intended_vx_ = 0.0F;
intended_vy_ = 0.0F;
input_vx_ = 0.0F;
input_vy_ = 0.0F;
being_pushed_ = false;
jump_z_ = 0.0F;
jump_vz_ = 0.0F;
falling_through_hole_ = false;
hole_clip_bottom_y_ = 0.0F;
landing_stun_remaining_ms_ = 0.0F;
attacking_remaining_ms_ = 0.0F;
turning_remaining_ms_ = 0.0F;
pending_cardinal_ = Cardinal::NONE;
intermediate_cardinal_ = Cardinal::NONE;
last_input_cardinal_ = Cardinal::NONE;
// Flags del cicle DYING (que `respawnFromDying` també neteja, però
// si algú crida `respawn` directament des d'un altre estat, han de
// quedar coherents per al pròxim cicle de mort).
death_animation_complete_ = false;
respawn_remaining_ms_ = 0.0F;
// Reseteig del facing al d'spawn original (mirant cap a la càmera):
// sense això, un EXITED que entrava per la porta amb facing NORTH
// re-aparix d'esquena al pròxim cicle. Mateixa lògica que el ctor.
facing_ = Facing::SOUTH;
setCurrentAnimation("stand_south");
setPosX(x);
setPosY(y);
}
void Player::disable() {
if (state_ == State::DISABLED) { return; }
state_ = State::DISABLED;
intended_vx_ = 0.0F;
intended_vy_ = 0.0F;
input_vx_ = 0.0F;
input_vy_ = 0.0F;
being_pushed_ = false;
jump_z_ = 0.0F;
jump_vz_ = 0.0F;
landing_stun_remaining_ms_ = 0.0F;
turning_remaining_ms_ = 0.0F;
pending_cardinal_ = Cardinal::NONE;
intermediate_cardinal_ = Cardinal::NONE;
last_input_cardinal_ = Cardinal::NONE;
}
void Player::enterHoleFall(float center_x, float center_y, float floor_y) {
// Cap guard de state ací: la responsabilitat la té el caller perquè
// hi ha dos callers vàlids amb states distints:
// - `Hole::onPlayerContact` (state GROUNDED, el jugador camina i
// trepitja el tile del forat).
// - `Player::integrateJump` (state AIRBORNE, en aterrar damunt
// d'un decal-trigger en lloc d'entrar a LANDING_STUN).
// Posar `if (state_ != GROUNDED) return` ací bloquejaria el segon
// cas i obligaria a hacks tipus "forçar GROUNDED abans de cridar".
// Snap del centre de la hit-box a la coordenada central del forat.
// Resolem `pos = center - (hit_offset + hit_size/2)` per a cada eix:
// així el centre exacte de la caja de contacte queda alineat amb
// (center_x, center_y), independentment de la geometria interna del
// sprite. `setPosX/Y` és l'API canònica per teleportar el jugador
// (la usen `respawn` i `spawnFromAir`).
const float HIT_HALF = static_cast<float>(hit_size) * 0.5F;
setPosX(center_x - static_cast<float>(hit_x_offset) - HIT_HALF);
setPosY(center_y - static_cast<float>(hit_y_offset) - HIT_HALF);
// Frenem qualsevol inèrcia prèvia: la caiguda ha de ser estàtica al
// centre del forat.
intended_vx_ = 0.0F;
intended_vy_ = 0.0F;
input_vx_ = 0.0F;
input_vy_ = 0.0F;
motion_time_ms_ = 0.0F;
being_pushed_ = false;
setVelX(0.0F);
setVelY(0.0F);
jump_z_ = 0.0F;
jump_vz_ = 0.0F;
state_ = State::AIRBORNE;
falling_through_hole_ = true;
// Línia visual on el clip rect ha de retallar el sprite. Restem
// `SPRITE_FLOOR_OFFSET` perquè la base del sprite té píxels
// transparents — el retall ha de coincidir amb la silueta visible,
// no amb la vora del rect del sprite.
hole_clip_bottom_y_ = floor_y - SPRITE_FLOOR_OFFSET;
// Animació de caiguda: respectem el `facing_` que ja portava el jugador
// (no té sentit reorientar-lo cap al sud només perquè caigui en un
// forat). Com `state_` ja és AIRBORNE, `animationName(false)` resol el
// prefix `jump_` i hi afegeix el sufix cardinal correcte —
// `jump_north`, `jump_south`, `jump_east` o `jump_west` segons el
// facing actual. Si demà cal una animació de "cara de pànic", només
// cal canviar aquí pel `setCurrentAnimation` corresponent.
setCurrentAnimation(animationName(false));
}
void Player::spawnFromAir(float x, float y) {
// Net total: equival a un `respawn` seguit d'una entrada en l'aire.
// Així el flag de gir, l'animació, els flancs del salt i les
// velocitats residuals queden a un estat conegut abans de promocionar
// a AIRBORNE. Si hi havia un cardinal d'input penjat de la sessió
// anterior, no contamina la pròxima caiguda.
respawn(x, y);
// Altura derivada de la geometria: prou per arrencar amb el sprite
// sencer per damunt del bord superior del canvas (recordem que
// visualment es pinta a `pos_.y - jump_z_`). Afegim un xicotet marge
// perquè el primer frame ja siga clarament off-screen, no just al ras.
constexpr float OFFSCREEN_MARGIN_PX = 8.0F;
const float SPAWN_HEIGHT = y + static_cast<float>(sprite_h) + OFFSCREEN_MARGIN_PX;
state_ = State::AIRBORNE;
jump_z_ = SPAWN_HEIGHT;
// Caiguda recta: sense impuls inicial, deixem que la gravetat (la
// mateixa que la del salt normal) faça la feina. Velocitat horitzontal
// a zero perquè no es desviï del passadís d'spawn.
jump_vz_ = 0.0F;
intended_vx_ = 0.0F;
intended_vy_ = 0.0F;
// Mirant cap avall durant la caiguda — `animationName(AIRBORNE)` amb
// facing SOUTH ja resol a `jump_south`. `respawn` ja ha posat facing a
// SOUTH; ho deixem explícit ací per claredat de la cinemàtica.
facing_ = Facing::SOUTH;
setCurrentAnimation("jump_south");
}
auto Player::getBaseBox() const -> SDL_FRect {
return {
.x = getPosX() + static_cast<float>(collision_x_offset),
.y = getPosY() + static_cast<float>(collision_y_offset),
.w = static_cast<float>(collision_size),
.h = static_cast<float>(collision_size),
};
}
auto Player::getHitBox() const -> SDL_FRect {
return {
.x = getPosX() + static_cast<float>(hit_x_offset),
.y = getPosY() + static_cast<float>(hit_y_offset),
.w = static_cast<float>(hit_size),
.h = static_cast<float>(hit_size),
};
}
auto Player::hitBoxFromBase(const SDL_FRect& base) -> SDL_FRect {
// Desplaçament intern respecte al cantó de la caixa base: mateixos
// offsets que `getHitBox()` però partint d'una caixa arbitrària.
const int INSET = (collision_size - hit_size) / 2;
return {
.x = base.x + static_cast<float>(INSET),
.y = base.y + static_cast<float>(INSET),
.w = static_cast<float>(hit_size),
.h = static_cast<float>(hit_size),
};
}
void Player::snapToLane(
float delta_time,
float base,
float pitch,
int max_idx,
float (Player::*get_pos)() const,
void (Player::*set_pos)(float)) {
// Lanes en pos_axis: base + i·pitch per a i ∈ [0, max_idx]. Correspon a
// la caixa de col·lisió ocupant exactament un passadís perpendicular.
const auto THRESHOLD = static_cast<float>(collision_size) / 2.0F;
const auto IDX = std::clamp(
static_cast<int>(std::round(((this->*get_pos)() - base) / pitch)),
0,
max_idx);
const float LANE = base + (static_cast<float>(IDX) * pitch);
const float DELTA = LANE - (this->*get_pos)();
if (std::abs(DELTA) <= 0.0F || std::abs(DELTA) > THRESHOLD) { return; }
const float FULL_STEP = std::min(std::abs(DELTA), SPEED_PX_PER_S * delta_time);
const float SAVED = (this->*get_pos)();
// Fase 1: snap a velocitat plena. Si no xoca amb res, commit — és el
// cas habitual (carril lliure) i no ha de patir la friccó de l'empenta.
(this->*set_pos)(SAVED + std::copysign(FULL_STEP, DELTA));
if (!collidesWithWorld(baseBox())) { return; }
// Mur al carril objectiu: no hi ha ningú a qui empentar, revert directe.
if (map_.collides(baseBox())) {
(this->*set_pos)(SAVED);
return;
}
// Fase 2: sols un altre jugador al carril. Reintentem amb step escalat
// per `push_factor_` (mateixa friccó que el push horitzontal del
// resolver) i mirem d'arrossegar els blockers. Si la pushada no cap
// (contra mur o tercer jugador), el snap es revertix sencer.
const float PUSH_STEP = std::min(std::abs(DELTA), SPEED_PX_PER_S * push_factor_ * delta_time);
const float SIGNED_PUSH = std::copysign(PUSH_STEP, DELTA);
(this->*set_pos)(SAVED + SIGNED_PUSH);
if (collidesWithWorld(baseBox())) {
if (map_.collides(baseBox()) || !tryPushSnapBlockers(SIGNED_PUSH, get_pos, set_pos)) {
(this->*set_pos)(SAVED);
}
}
}
auto Player::tryPushSnapBlockers(
float signed_step,
float (Player::*get_pos)() const,
void (Player::*set_pos)(float)) -> bool {
if (all_players_ == nullptr) { return false; }
// Blockers = jugadors que ara mateix solapen la nostra hit-box (ja
// amb el snap aplicat). Mateix criteri actor-actor que `collidesWithWorld`.
std::vector<Player*> blockers;
const SDL_FRect MY_HIT = getHitBox();
for (Player* p : *all_players_) {
if (p == nullptr || p == this) { continue; }
if (p->isOutOfPlay()) { continue; }
const SDL_FRect OTHER_HIT = p->getHitBox();
if (SDL_HasRectIntersectionFloat(&MY_HIT, &OTHER_HIT)) {
blockers.push_back(p);
}
}
if (blockers.empty()) { return false; }
// Desem la posició original de cada blocker i el desplacem el mateix
// step que s'ha aplicat a `this`: com que els dos es mouen igual en el
// mateix eix, la distància relativa es conserva i, per tant, si abans
// del snap no es solapaven, després de l'empenta tampoc.
std::vector<float> saved;
saved.reserve(blockers.size());
for (Player* p : blockers) {
const float S = (p->*get_pos)();
saved.push_back(S);
(p->*set_pos)(S + signed_step);
}
// Validació: `this` ha de quedar net (ni mur ni cap jugador), i cada
// blocker també (cobreix el cas de 3+ jugadors on un blocker podria
// acabar dins d'un tercer o contra un mur).
const bool OK = !collidesWithWorld(baseBox()) && std::ranges::none_of(blockers, [](const Player* p) {
return p->collidesWithWorld(p->getBaseBox());
});
if (!OK) {
for (size_t i = 0; i < blockers.size(); ++i) {
(blockers[i]->*set_pos)(saved[i]);
}
return false;
}
// Els blockers s'han desplaçat sense input propi: mateixa convenció
// d'animació que el push horitzontal del resolver (stand_*, no walk).
for (Player* p : blockers) {
p->setBeingPushed(true);
}
return true;
}
void Player::snapToHorizontalLane(float delta_time) {
snapToLane(delta_time,
static_cast<float>(map_.playfield_y - collision_y_offset),
static_cast<float>(map_.pitch_y),
map_.grid_rows,
&Player::getPosY,
&Player::setPosY);
}
void Player::snapToVerticalLane(float delta_time) {
snapToLane(delta_time,
static_cast<float>(map_.playfield_x - collision_x_offset),
static_cast<float>(map_.pitch_x),
map_.grid_cols,
&Player::getPosX,
&Player::setPosX);
}