1205 lines
51 KiB
C++
1205 lines
51 KiB
C++
#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_manager.hpp"
|
||
#include "core/input/input_state.hpp"
|
||
#include "core/rendering/screen.hpp"
|
||
#include "core/resources/resource_cache.hpp"
|
||
#include "core/resources/resource_helper.hpp"
|
||
#include "external/fkyaml_node.hpp"
|
||
#include "game/entities/event_bus.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;
|
||
|
||
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);
|
||
}
|
||
|
||
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>(); }
|
||
}
|
||
|
||
void Player::applyCardinal(Cardinal c) {
|
||
switch (c) {
|
||
case Cardinal::NORTH:
|
||
facing_ = Facing::UP;
|
||
setFlip(SDL_FLIP_NONE);
|
||
break;
|
||
case Cardinal::SOUTH:
|
||
facing_ = Facing::DOWN;
|
||
setFlip(SDL_FLIP_NONE);
|
||
break;
|
||
case Cardinal::EAST:
|
||
facing_ = Facing::SIDE;
|
||
setFlip(SDL_FLIP_NONE);
|
||
break;
|
||
case Cardinal::WEST:
|
||
facing_ = Facing::SIDE;
|
||
setFlip(SDL_FLIP_HORIZONTAL);
|
||
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" || s == "up") { return Cardinal::NORTH; }
|
||
if (s == "south" || s == "down") { return Cardinal::SOUTH; }
|
||
if (s == "east" || s == "right") { return Cardinal::EAST; }
|
||
if (s == "west" || s == "left") { 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 original: prevalença lateral, flip segons el
|
||
// signe de vx; verticals purs van a UP/DOWN amb flip NONE; en
|
||
// aturar-se conservem `facing_` per a l'animació stand.
|
||
facing_ = Facing::SIDE;
|
||
setFlip(vx < 0.0F ? SDL_FLIP_HORIZONTAL : SDL_FLIP_NONE);
|
||
} else if (vy < 0.0F) {
|
||
facing_ = Facing::UP;
|
||
setFlip(SDL_FLIP_NONE);
|
||
} else if (vy > 0.0F) {
|
||
facing_ = Facing::DOWN;
|
||
setFlip(SDL_FLIP_NONE);
|
||
}
|
||
}
|
||
|
||
// 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 DOWN i l'animació `stand_down` abans del primer
|
||
// frame d'input — el primer `update` la preservarà si l'usuari no
|
||
// prem cap tecla.
|
||
facing_ = Facing::DOWN;
|
||
setCurrentAnimation("stand_down");
|
||
}
|
||
|
||
void Player::update(float delta_time) {
|
||
tick(delta_time);
|
||
applyMovement(delta_time);
|
||
}
|
||
|
||
auto Player::collidesWithWorld(const SDL_FRect& box) const -> bool {
|
||
// EXITED: jugador despawnat. No xoca amb res ni res xoca amb ell.
|
||
// També salta jugadors EXITED del check actor↔actor més avall via
|
||
// el filtre de `isExited` (vegeu el predicat any_of).
|
||
if (state_ == State::EXITED) { 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_;
|
||
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 (p->isExited()) { 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: jugador despawnat, 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 al respawn.
|
||
if (state_ == State::EXITED) { 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();
|
||
return;
|
||
}
|
||
|
||
// En AIRBORNE el jugador no llig input direccional: la velocitat
|
||
// horitzontal queda fixada al moment del despegue. Tampoc canvia
|
||
// facing. Sí actualitzem els flancs del botó perquè la propera
|
||
// pulsació quan torne a terra es detecte com a flanc nou.
|
||
if (state_ == State::AIRBORNE) {
|
||
(void)consumeJumpEdge();
|
||
setVelX(intended_vx_);
|
||
setVelY(intended_vy_);
|
||
return;
|
||
}
|
||
|
||
// 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<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;
|
||
}
|
||
|
||
// 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ó de salt. Es processa DESPRÉS d'aplicar
|
||
// el facing perquè el sprite del salt usa el facing del moment del
|
||
// despegue. `being_pushed_` no ho pot bloquejar ací (encara no s'ha
|
||
// marcat aquest frame, ho fa el resolver entre tick i applyMovement);
|
||
// ho gestionem deixant que el resolver vinga, i si en el moment del
|
||
// despegue (consumeJumpEdge → startJump) ja estem AIRBORNE, l'altre
|
||
// braç del switch no canvia res.
|
||
if (consumeJumpEdge() && jump_config.impulse_px_per_s > 0.0F) {
|
||
startJump();
|
||
}
|
||
}
|
||
|
||
auto Player::consumeJumpEdge() -> bool {
|
||
// Edge-detection independent per teclat i mando: si qualsevol dels
|
||
// dos passa de no-premut a premut aquest frame, comptem com a salt.
|
||
// Sense detecció de flanc, mantenir la tecla provocaria saltar
|
||
// immediatament en aterrar.
|
||
const bool* keys = InputState::getKeyboardState();
|
||
const bool KEY_NOW = keys[controls_.jump];
|
||
bool edge = (KEY_NOW && !jump_held_keyboard_);
|
||
jump_held_keyboard_ = KEY_NOW;
|
||
|
||
SDL_Gamepad* pad = GamepadManager::get().getForPlayer(id_);
|
||
const bool PAD_NOW = (pad != nullptr) && InputState::getGamepadButton(pad, SDL_GAMEPAD_BUTTON_SOUTH);
|
||
if (PAD_NOW && !jump_held_gamepad_) { edge = true; }
|
||
jump_held_gamepad_ = PAD_NOW;
|
||
|
||
return edge;
|
||
}
|
||
|
||
void Player::startJump() {
|
||
// No saltem si l'estat no ho permet. `being_pushed_` no és segur de
|
||
// consultar ací (es pot haver marcat o no, segons l'ordre del frame),
|
||
// així que la regla és: només GROUNDED arrenca salts. El push viu
|
||
// entre `tick` i `applyMovement`, així que si el resolver acaba
|
||
// empentant en mig d'un frame on intentàvem saltar, el flanc es perd
|
||
// — comportament acceptable: l'empenta guanya al toc curt.
|
||
if (state_ != State::GROUNDED) { return; }
|
||
|
||
// Un gir de 180° en curs es compromet immediatament: si l'usuari
|
||
// ha disparat un giro (oest→est, p.ex.) i salta abans que s'esgote
|
||
// el timer, l'animació del salt ha de mostrar la direcció final
|
||
// (jump lateral), no la intermèdia (jump_down). Aplem el pending
|
||
// ara mateix i resetem l'estat del gir. Si no hi havia gir en
|
||
// curs, és no-op.
|
||
if (turning_remaining_ms_ > 0.0F) {
|
||
applyCardinal(pending_cardinal_);
|
||
turning_remaining_ms_ = 0.0F;
|
||
pending_cardinal_ = Cardinal::NONE;
|
||
intermediate_cardinal_ = Cardinal::NONE;
|
||
}
|
||
|
||
state_ = State::AIRBORNE;
|
||
jump_z_ = 0.0F;
|
||
jump_vz_ = jump_config.impulse_px_per_s;
|
||
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;
|
||
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;
|
||
if (jump_z_ <= 0.0F) {
|
||
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) {
|
||
Audio::get()->playSound(jump_config.land_sound_key, Audio::Group::GAME);
|
||
}
|
||
if (bus_ != nullptr) {
|
||
bus_->publish(Event{
|
||
.name = "player_landed",
|
||
.payload = WorldPos{.x = getPosX() + (static_cast<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; }
|
||
// 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: cap moviment ni col·lisió. Idèntic al gating de `tick`.
|
||
if (state_ == State::EXITED) { 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::HURT) {
|
||
applyHurtMovement(delta_time);
|
||
return;
|
||
}
|
||
|
||
if (state_ == State::LANDING_STUN) {
|
||
applyLandingStunMovement(delta_time);
|
||
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::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: invisible. La crida al render base es salta sencera; el
|
||
// jugador no apareix al z-sort tot i seguir vivint al vector.
|
||
if (state_ == State::EXITED) { return; }
|
||
|
||
// Mentre el jugador està enlaire, el sprite es desplaça visualment
|
||
// cap amunt `jump_z_` píxels. Modifiquem `pos_.y` (la posició entera
|
||
// de render) abans de cridar el render base, i la restaurem després
|
||
// perquè l'estat seguent del frame ho recalcule des de `y_` (la font
|
||
// de veritat float). El sòl sota el jugador no es pinta des d'ací:
|
||
// això ho fa el GameScene en una passada de sombres a banda.
|
||
if (jump_z_ <= 0.0F) {
|
||
AnimatedSprite::render();
|
||
return;
|
||
}
|
||
const float SAVED_Y = pos_.y;
|
||
pos_.y = SAVED_Y - jump_z_;
|
||
AnimatedSprite::render();
|
||
pos_.y = SAVED_Y;
|
||
}
|
||
|
||
void Player::renderShadow() const {
|
||
// Sense salt actiu, no hi ha sombra: això simplifica els callsites,
|
||
// que poden cridar `renderShadow` per a tots els jugadors sense
|
||
// discriminar.
|
||
if (!isAirborne()) { return; }
|
||
|
||
// Sombra centrada al sprite, recolzada a la línia dels peus reals
|
||
// (ignorant `jump_z_`). L'amplada es redueix proporcional a l'altura
|
||
// del salt — al pic queda més estreta, donant la sensació de
|
||
// "elevació" sense necessitat de coordenades 3D reals. Si el
|
||
// jump_config té un divisor zero (cas degenerat), només dibuixem
|
||
// la sombra a mida plena per evitar nan.
|
||
const auto PEAK = (jump_config.gravity_px_per_s2 > 0.0F)
|
||
? (jump_config.impulse_px_per_s * jump_config.impulse_px_per_s) / (2.0F * jump_config.gravity_px_per_s2)
|
||
: 0.0F;
|
||
const float NORM_Z = (PEAK > 0.0F) ? std::clamp(jump_z_ / PEAK, 0.0F, 1.0F) : 0.0F;
|
||
constexpr float MAX_SHRINK = 0.4F; // Fins a un 40% més estreta al pic.
|
||
const float SHRINK = 1.0F - (NORM_Z * MAX_SHRINK);
|
||
|
||
constexpr int SHADOW_H = 3;
|
||
const auto SHADOW_W = static_cast<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);
|
||
}
|
||
|
||
auto Player::animationName(bool moving) const -> const char* {
|
||
// En l'aire forcem la variant `jump_*` (per direcció), independent de
|
||
// si hi ha velocitat horitzontal: per al jugador, l'estat visual del
|
||
// salt és per direcció, no per velocitat.
|
||
if (state_ == State::AIRBORNE) {
|
||
switch (facing_) {
|
||
case Facing::UP:
|
||
return "jump_up";
|
||
case Facing::DOWN:
|
||
return "jump_down";
|
||
case Facing::SIDE:
|
||
break;
|
||
}
|
||
return "jump";
|
||
}
|
||
// En LANDING_STUN, `stun_*` (per direcció): mostrem el frame
|
||
// d'aterratge fins que s'esgota el timer. La direcció ve fixada del
|
||
// moment del despegue (no es canvia mentre estem en l'aire).
|
||
if (state_ == State::LANDING_STUN) {
|
||
switch (facing_) {
|
||
case Facing::UP:
|
||
return "stun_up";
|
||
case Facing::DOWN:
|
||
return "stun_down";
|
||
case Facing::SIDE:
|
||
break;
|
||
}
|
||
return "stun";
|
||
}
|
||
// 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";
|
||
case Facing::DOWN:
|
||
return moving ? "walk_down" : "stand_down";
|
||
case Facing::SIDE:
|
||
break;
|
||
}
|
||
return moving ? "walk" : "stand";
|
||
}
|
||
|
||
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;
|
||
landing_stun_remaining_ms_ = 0.0F;
|
||
turning_remaining_ms_ = 0.0F;
|
||
pending_cardinal_ = Cardinal::NONE;
|
||
intermediate_cardinal_ = Cardinal::NONE;
|
||
last_input_cardinal_ = Cardinal::NONE;
|
||
// Reseteig del facing al d'spawn original (mirant cap a la càmera):
|
||
// sense això, un EXITED que entrava per la porta amb facing UP
|
||
// re-aparix d'esquena al pròxim cicle. Mateixa lògica que el ctor.
|
||
facing_ = Facing::DOWN;
|
||
setFlip(SDL_FLIP_NONE);
|
||
setCurrentAnimation("stand_down");
|
||
setPosX(x);
|
||
setPosY(y);
|
||
}
|
||
|
||
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; }
|
||
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);
|
||
}
|