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

824 lines
35 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 <cmath>
#include <stdexcept>
#include <utility>
#include "core/input/gamepad_manager.hpp"
#include "core/input/input_state.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;
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);
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;
// 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);
}
void Player::readJumpBlock(const fkyaml::node& yaml) {
jump_config = JumpConfig{};
body_height = 0;
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("height")) { body_height = j["height"].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 {
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.
//
// Filtre per altura: si aquest jugador (this) està en l'aire i passa
// per damunt del cos de l'altre, no hi ha col·lisió — saltar permet
// sobrevolar l'altre. `body_height` és estàtic (compartit entre
// jugadors), però per al filtre val la pena consultar-lo per actor
// perquè en el futur cada character podria tenir-ne un de propi.
const SDL_FRect PROPOSED_HIT = hitBoxFromBase(box);
const float Z = jump_z_;
const bool AIRBORNE = (state_ == State::AIRBORNE);
return std::ranges::any_of(*all_players_, [this, &PROPOSED_HIT, AIRBORNE, Z](const Player* p) {
if (p == nullptr || p == this) { return false; }
if (AIRBORNE && body_height > 0 && 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);
}
}
void Player::tick(float delta_time) {
// Reset per frame: el resolver del GameScene el tornarà a posar a
// true si aquest frame hi ha push actiu. Així quan el push acaba
// (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;
}
// 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; }
state_ = State::AIRBORNE;
jump_z_ = 0.0F;
jump_vz_ = jump_config.impulse_px_per_s;
// 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::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);
return true;
}
return false;
}
void Player::applyMovement(float delta_time) {
// 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 ja al final d'`integrateJump`.
integrateJump(delta_time);
// En LANDING_STUN no movem el jugador horitzontalment (la velocitat
// ja s'ha posat a 0 al `tick`). Encara així animem (per estar al
// frame correcte de la posada estàtica) i emetem el tile actual,
// perquè la tile d'aterratge sí que es pinte.
if (state_ == State::LANDING_STUN) {
animate(delta_time);
setCurrentAnimation(animationName(false));
if (bus_ != nullptr) {
const auto [COL, ROW] = getCurrentTile();
if (COL != last_tile_col_ || ROW != last_tile_row_) {
last_tile_col_ = COL;
last_tile_row_ = ROW;
bus_->publish(Event{
.name = "on_tile_entered",
.payload = PaintedTile{.col = COL, .row = ROW, .tile_index = paint_tile_index_},
.player_id = id_,
});
}
}
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. Es calcula *després* del clamp i de les
// col·lisions perquè la posició aquí ja és la definitiva del frame;
// així el PaintLayer no rep falsos positius mentre el Player rellisca
// contra una paret. Si el tile ha canviat, publiquem `on_tile_entered`
// al bus amb l'índex de pintat d'aquest jugador; el PaintLayer filtra
// pintabilitat i re-publica `tile_painted` als sarcòfags.
//
// En AIRBORNE NO publiquem cap pintat — el jugador està en l'aire i
// no està trepitjant cap tile. NI tampoc actualitzem `last_tile_*`,
// perquè volem que el tile d'aterratge (que es comprova al
// LANDING_STUN) compari contra l'últim tile real (el del despegue) i
// s'emeti com a entrada nova si han canviat.
if (bus_ != nullptr && state_ == State::GROUNDED) {
const auto [COL, ROW] = getCurrentTile();
if (COL != last_tile_col_ || ROW != last_tile_row_) {
last_tile_col_ = COL;
last_tile_row_ = ROW;
bus_->publish(Event{
.name = "on_tile_entered",
.payload = PaintedTile{.col = COL, .row = ROW, .tile_index = paint_tile_index_},
.player_id = id_,
});
}
}
}
void Player::render() {
// 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;
}
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";
}
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);
const 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);
setPosX(std::clamp(getPosX(), MIN_X, MAX_X));
setPosY(std::clamp(getPosY(), MIN_Y, MAX_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);
}