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

1957 lines
85 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/rendering/canvas.hpp"
#include "core/rendering/effects/shadow.hpp"
#include "core/rendering/screen.hpp"
#include "core/rendering/surface.hpp"
#include "core/resources/palette_loader.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/gameplay_config.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;
// 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 (SDL_GetGamepadButton(pad, SDL_GAMEPAD_BUTTON_DPAD_LEFT)) { vx -= SPEED_PX_PER_S; }
if (SDL_GetGamepadButton(pad, SDL_GAMEPAD_BUTTON_DPAD_RIGHT)) { vx += SPEED_PX_PER_S; }
if (SDL_GetGamepadButton(pad, SDL_GAMEPAD_BUTTON_DPAD_UP)) { vy -= SPEED_PX_PER_S; }
if (SDL_GetGamepadButton(pad, SDL_GAMEPAD_BUTTON_DPAD_DOWN)) { vy += SPEED_PX_PER_S; }
const Sint16 LX = SDL_GetGamepadAxis(pad, SDL_GAMEPAD_AXIS_LEFTX);
const Sint16 LY = SDL_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
namespace {
// ---- Helpers de lectura per sub-blocs del YAML del personatge ----
// Cada helper rep el bloc YAML root i un `Player::Config&` a poblar.
// Camps absents respecten els defaults del Config (no reinicialitzen
// a 0 — la Config ja ve construïda amb els valors default abans).
void readSpriteSizeInto(const fkyaml::node& yaml, const std::string& yaml_path, Player::Config& out) {
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"];
out.sprite_w = size_node[0].get_value<int>();
out.sprite_h = size_node[1].get_value<int>();
}
void readCollisionInto(const fkyaml::node& yaml, const std::string& yaml_path, Player::Config& out) {
if (!yaml.contains("collision")) {
throw std::runtime_error("Player: manca bloc `collision:` a " + yaml_path);
}
const auto& c = yaml["collision"];
out.collision_size = c["size"].get_value<int>();
out.base_size = c["base_size"].get_value<int>();
// Col·lisió centrada en el quadrat inferior del sprite.
out.collision_padding = (out.collision_size - out.base_size) / 2;
out.collision_x_offset = -out.collision_padding;
out.collision_y_offset = (out.sprite_h - out.base_size) - out.collision_padding;
// Hit-box opcional (actor-actor): default = caixa de col·lisió.
out.hit_size = c.contains("hit_size") ? c["hit_size"].get_value<int>() : out.collision_size;
const int HIT_INSET = (out.collision_size - out.hit_size) / 2;
out.hit_x_offset = out.collision_x_offset + HIT_INSET;
out.hit_y_offset = out.collision_y_offset + HIT_INSET;
out.body_height = c.contains("height") ? c["height"].get_value<int>() : 0;
}
void readInertiaInto(const fkyaml::node& yaml, Player::Config& out) {
if (!yaml.contains("inertia")) { return; }
const auto& i = yaml["inertia"];
if (i.contains("brake_time_ms")) { out.inertia.brake_time_ms = i["brake_time_ms"].get_value<int>(); }
if (i.contains("threshold_ms")) { out.inertia.threshold_ms = i["threshold_ms"].get_value<int>(); }
}
void readTurnInto(const fkyaml::node& yaml, Player::Config& out) {
if (!yaml.contains("turn")) { return; }
const auto& t = yaml["turn"];
if (t.contains("duration_ms")) { out.turn.duration_ms = t["duration_ms"].get_value<int>(); }
if (!t.contains("transitions")) { return; }
const auto& tr = t["transitions"];
if (tr.contains("west_to_east")) { out.turn.w_to_e = Player::cardinalFromString(tr["west_to_east"].get_value<std::string>()); }
if (tr.contains("east_to_west")) { out.turn.e_to_w = Player::cardinalFromString(tr["east_to_west"].get_value<std::string>()); }
if (tr.contains("north_to_south")) { out.turn.n_to_s = Player::cardinalFromString(tr["north_to_south"].get_value<std::string>()); }
if (tr.contains("south_to_north")) { out.turn.s_to_n = Player::cardinalFromString(tr["south_to_north"].get_value<std::string>()); }
}
void readJumpInto(const fkyaml::node& yaml, Player::Config& out) {
if (!yaml.contains("jump")) { return; }
const auto& j = yaml["jump"];
if (j.contains("impulse")) { out.jump.impulse_px_per_s = j["impulse"].get_value<float>(); }
if (j.contains("gravity")) { out.jump.gravity_px_per_s2 = j["gravity"].get_value<float>(); }
if (j.contains("landing_stun_ms")) { out.jump.landing_stun_ms = j["landing_stun_ms"].get_value<int>(); }
if (j.contains("sound")) {
out.jump.sound_key = j["sound"].get_value<std::string>();
Resource::Cache::get().loadSound(out.jump.sound_key, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + out.jump.sound_key);
}
if (j.contains("land_sound")) {
out.jump.land_sound_key = j["land_sound"].get_value<std::string>();
Resource::Cache::get().loadSound(out.jump.land_sound_key, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + out.jump.land_sound_key);
}
}
void readHurtInto(const fkyaml::node& yaml, Player::Config& out) {
if (!yaml.contains("hurt")) { return; }
const auto& h = yaml["hurt"];
if (h.contains("speed")) { out.hurt.initial_speed_px_per_s = h["speed"].get_value<float>(); }
if (h.contains("brake_time_ms")) { out.hurt.brake_time_ms = h["brake_time_ms"].get_value<int>(); }
if (h.contains("sound")) {
out.hurt.sound_key = h["sound"].get_value<std::string>();
Resource::Cache::get().loadSound(out.hurt.sound_key, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + out.hurt.sound_key);
}
}
void readWhipInto(const fkyaml::node& yaml, Player::Config& out) {
if (!yaml.contains("whip")) { return; }
const auto& w = yaml["whip"];
if (w.contains("range_tiles")) { out.whip.range_tiles = w["range_tiles"].get_value<float>(); }
if (w.contains("swing_ms")) { out.whip.swing_ms = w["swing_ms"].get_value<int>(); }
if (w.contains("cooldown_ms")) { out.whip.cooldown_ms = w["cooldown_ms"].get_value<int>(); }
if (w.contains("damage")) { out.whip.damage = w["damage"].get_value<int>(); }
if (w.contains("sound")) {
out.whip.sound_key = w["sound"].get_value<std::string>();
Resource::Cache::get().loadSound(out.whip.sound_key, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + out.whip.sound_key);
}
}
void readDeathInto(const fkyaml::node& yaml, Player::Config& out) {
if (!yaml.contains("death")) { return; }
const auto& d = yaml["death"];
if (d.contains("respawn_delay_ms")) { out.death.respawn_delay_ms = d["respawn_delay_ms"].get_value<int>(); }
if (d.contains("lives")) { out.death.initial_lives = d["lives"].get_value<int>(); }
}
void readImmunityInto(const fkyaml::node& yaml, Player::Config& out) {
if (!yaml.contains("immunity")) { return; }
const auto& im = yaml["immunity"];
if (im.contains("duration_ms")) { out.immunity.duration_ms = im["duration_ms"].get_value<float>(); }
if (im.contains("blink_period_ms")) { out.immunity.blink_period_ms = im["blink_period_ms"].get_value<int>(); }
if (im.contains("palette")) {
out.immunity.palette_key = im["palette"].get_value<std::string>();
Resource::Cache::get().loadPalette(out.immunity.palette_key, std::string{Defaults::Paths::PLAYER_DIR} + out.immunity.palette_key + ".pal");
}
}
void readLightInto(const fkyaml::node& yaml, Player::Config& out) {
// Punt de partida del flicker: el global de lighting.yaml. El bloc
// opcional `flicker_params:` del personatge sobreescriu camps concrets.
out.flicker_params = Lighting::flickerConfig();
if (!yaml.contains("light")) { return; }
const auto& l = yaml["light"];
if (l.contains("radius")) { out.light_radius = l["radius"].get_value<int>(); }
if (l.contains("flicker")) { out.light_flicker = l["flicker"].get_value<bool>(); }
if (l.contains("color")) {
const auto& c = l["color"];
if (c.is_sequence() && c.size() == 3) {
out.light_color_r = static_cast<std::uint8_t>(std::clamp(c[0].get_value<int>(), 0, 255));
out.light_color_g = static_cast<std::uint8_t>(std::clamp(c[1].get_value<int>(), 0, 255));
out.light_color_b = static_cast<std::uint8_t>(std::clamp(c[2].get_value<int>(), 0, 255));
}
}
if (l.contains("offset")) {
const auto& o = l["offset"];
if (o.is_sequence() && o.size() == 2) {
out.light_offset_x = o[0].get_value<int>();
out.light_offset_y = o[1].get_value<int>();
}
}
if (l.contains("flicker_params")) {
const auto& fp = l["flicker_params"];
if (fp.contains("wave_amplitude_ratio")) { out.flicker_params.wave_amplitude_ratio = fp["wave_amplitude_ratio"].get_value<float>(); }
if (fp.contains("wave_frequency_hz")) { out.flicker_params.wave_frequency_hz = fp["wave_frequency_hz"].get_value<float>(); }
if (fp.contains("noise_amplitude_ratio")) { out.flicker_params.noise_amplitude_ratio = fp["noise_amplitude_ratio"].get_value<float>(); }
if (fp.contains("noise_frequency_hz")) { out.flicker_params.noise_frequency_hz = fp["noise_frequency_hz"].get_value<float>(); }
}
}
} // namespace
auto Player::Config::loadFromYaml(const std::string& yaml_path) -> Player::Config {
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);
Player::Config out;
readSpriteSizeInto(yaml, yaml_path, out);
readCollisionInto(yaml, yaml_path, out);
readInertiaInto(yaml, out);
readTurnInto(yaml, out);
readJumpInto(yaml, out);
readHurtInto(yaml, out);
readWhipInto(yaml, out);
readDeathInto(yaml, out);
readImmunityInto(yaml, out);
readLightInto(yaml, out);
return out;
}
void Player::Config::applyRoomLightOverride() {
// Override de sala per al jugador (bloc `lighting.emitters.player`
// del YAML del mapa). Si la sala no en declara, els valors base del
// YAML del personatge es conserven. Si la sala desactiva
// (`players: false`), `light_radius = 0` (no emet).
const auto* room = Map::getRoomEmitterOverride("player");
if (room == nullptr) { return; }
if (!room->enabled) {
light_radius = 0;
light_flicker = false;
return;
}
if (room->radius.has_value()) { light_radius = *room->radius; }
if (room->flicker.has_value()) { light_flicker = *room->flicker; }
if (room->color.has_value()) {
light_color_r = (*room->color)[0];
light_color_g = (*room->color)[1];
light_color_b = (*room->color)[2];
}
if (room->offset.has_value()) {
light_offset_x = (*room->offset)[0];
light_offset_y = (*room->offset)[1];
}
if (room->flicker_wave_amplitude_ratio.has_value()) { flicker_params.wave_amplitude_ratio = *room->flicker_wave_amplitude_ratio; }
if (room->flicker_wave_frequency_hz.has_value()) { flicker_params.wave_frequency_hz = *room->flicker_wave_frequency_hz; }
if (room->flicker_noise_amplitude_ratio.has_value()) { flicker_params.noise_amplitude_ratio = *room->flicker_noise_amplitude_ratio; }
if (room->flicker_noise_frequency_hz.has_value()) { flicker_params.noise_frequency_hz = *room->flicker_noise_frequency_hz; }
}
void Player::setConfig(Config cfg) {
cfg_ = std::move(cfg);
}
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) const -> Cardinal {
switch (target) {
case Cardinal::EAST:
return cfg_.turn.w_to_e;
case Cardinal::WEST:
return cfg_.turn.e_to_w;
case Cardinal::SOUTH:
return cfg_.turn.n_to_s;
case Cardinal::NORTH:
return cfg_.turn.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 =
(cfg_.turn.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>(cfg_.turn.duration_ms);
applyCardinal(intermediate_cardinal_);
} else if (vy < 0.0F) {
// Tria de facing amb prevalença vertical: si hi ha component
// vertical, el sprite mira NORTH/SOUTH encara que també hi
// haja horitzontal. Així una diagonal (down+right, up+left,
// etc.) mostra el sprite mirant amunt o avall, no de costat.
// Només quan el moviment és purament horitzontal anem a
// EAST/WEST. En aturar-se conservem `facing_` per a stand.
facing_ = Facing::NORTH;
} else if (vy > 0.0F) {
facing_ = Facing::SOUTH;
} else if (vx != 0.0F) {
facing_ = (vx < 0.0F) ? Facing::WEST : Facing::EAST;
}
}
// 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(Config cfg, 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)),
cfg_(std::move(cfg)),
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_ = cfg_.death.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;
// Cachegem la paleta normal del sprite ara que la Surface ja està
// carregada. El render del blink d'immunitat la restaura després
// d'aplicar la paleta plana — fer-ho via membre evita dependre de
// l'estat momentani de la Surface (que pot ser compartida entre
// jugadors del mateix personatge).
if (surface_ != nullptr) { normal_palette_ = surface_->getPalette(); }
}
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; }
// Tweak `players.collide_with_each_other`: si està desactivat, els
// jugadors es travessen lliurement. La col·lisió contra el món
// (murs/sarcs) ja s'ha avaluat dalt; saltem només la part actor↔actor.
if (!GameplayConfig::get().playersCollide()) { return false; }
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 + cfg_.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 && cfg_.body_height > 0 && std::fabs(MY_Z - p->jump_z_) >= static_cast<float>(cfg_.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) const {
// 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 = (cfg_.inertia.brake_time_ms > 0) && (motion_time_ms >= static_cast<float>(cfg_.inertia.threshold_ms));
if (!INERTIA_ACTIVE) {
phys = 0.0F;
return;
}
const float DECEL = SPEED_PX_PER_S / (static_cast<float>(cfg_.inertia.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);
}
}
// 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.
void Player::tickDying() {
(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);
}
// 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.
void Player::tickLandingStun(float delta_time) {
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();
}
// 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.
void Player::tickAirborne() {
(void)consumeJumpEdge();
(void)consumeAttackEdge();
setVelX(intended_vx_);
setVelY(intended_vy_);
}
// 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.
void Player::tickAttacking(float delta_time) {
(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);
}
// 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.
void Player::tickHurt(float delta_time) {
(void)consumeJumpEdge();
(void)consumeAttackEdge();
const float DECEL = (cfg_.hurt.brake_time_ms > 0)
? cfg_.hurt.initial_speed_px_per_s / (static_cast<float>(cfg_.hurt.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_);
}
// Estat normal de joc (GROUNDED): llegeix input, integra física, actualitza
// facing i processa els flancs d'atac i salt.
void Player::tickGroundedInput(float delta_time) {
// 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();
const bool* keys = SDL_GetKeyboardState(nullptr);
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).
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() && cfg_.whip.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() && cfg_.jump.impulse_px_per_s > 0.0F) {
startJump();
}
}
// Dispatcher per estat. La complexitat real viu als handlers individuals.
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; }
// Decrement del timer d'immunitat. Independent de l'estat: si el
// jugador mor durant la immunitat (només via forat, que no es gateja),
// el `respawn` rearma una nova finestra. Si la finestra acaba mentre
// és AIRBORNE/HURT, no passa res — el gate dels hazards passa a
// quedar obert al primer frame post-zero.
if (immunity_remaining_ms_ > 0.0F) {
immunity_remaining_ms_ -= delta_time * 1000.0F;
immunity_remaining_ms_ = std::max(immunity_remaining_ms_, 0.0F);
}
if (state_ == State::DYING) {
tickDying();
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;
if (state_ == State::LANDING_STUN) {
tickLandingStun(delta_time);
return;
}
if (state_ == State::AIRBORNE) {
tickAirborne();
return;
}
if (state_ == State::ATTACKING) {
tickAttacking(delta_time);
return;
}
if (state_ == State::HURT) {
tickHurt(delta_time);
return;
}
tickGroundedInput(delta_time);
}
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 = SDL_GetKeyboardState(nullptr);
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) && 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 = SDL_GetKeyboardState(nullptr);
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) && 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>(cfg_.whip.swing_ms + cfg_.whip.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 (!cfg_.whip.sound_key.empty() && Audio::get() != nullptr) {
Audio::get()->playSound(cfg_.whip.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 = cfg_.whip.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_ = cfg_.jump.impulse_px_per_s;
if (!cfg_.jump.sound_key.empty() && Audio::get() != nullptr) {
Audio::get()->playSound(cfg_.jump.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>(cfg_.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 * cfg_.hurt.initial_speed_px_per_s * (static_cast<float>(cfg_.hurt.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) {
// Gate d'immunitat: tots els callers de dany (whip friendly fire,
// caiguda damunt, futurs hazards) passen per ací. Una sola línia
// cobreix la finestra de gràcia post-spawn.
if (isImmune()) { return; }
// Si la mecànica no està configurada (speed = 0), no fem res — el
// comportament queda equivalent al d'abans, sense empenta.
if (cfg_.hurt.initial_speed_px_per_s <= 0.0F || cfg_.hurt.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 (!cfg_.hurt.sound_key.empty() && Audio::get() != nullptr) {
Audio::get()->playSound(cfg_.hurt.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 = cfg_.hurt.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_ -= cfg_.jump.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>(cfg_.jump.landing_stun_ms);
if (!cfg_.jump.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(cfg_.jump.land_sound_key, Audio::Group::GAME);
} else {
Audio::get()->playSoundWithReverb(cfg_.jump.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>(cfg_.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.
//
// Tweak `players.collide_with_each_other`: si està desactivat, el
// saltador travessa l'altre net en lloc de projectar-lo. Aquest
// camí és independent de `collidesWithWorld`, així que cal aturar-lo
// explícitament aquí.
if (!GameplayConfig::get().playersCollide()) { return; }
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();
// Refinem el `facing_` amb el desplaçament REAL del frame (post
// col·lisió/snap), no amb la velocitat d'entrada: si l'usuari prem
// diagonal en un pasillo, `tryMove` ja haurà anul·lat l'eix tapat
// per la paret, així que el sprite mira en la direcció realment
// efectuada (la d'input cru no ho sap). En AIRBORNE el facing es
// preserva des del despegue (vegeu el comentari de `integrateJump`),
// i durant un gir actiu el cardinal intermedi mana, així que els
// saltem. Si DX==DY==0 (parat o totalment bloquejat) preservem el
// facing previ per a l'animació stand. La prioritat vertical en
// diagonals lliures és intranscendent en pasillos (un dels dos eixos
// serà sempre 0); manté coherència amb la tria d'`updateFacingAndTurn`.
if (!AIRBORNE && turning_remaining_ms_ <= 0.0F) {
const float DX = getPosX() - FRAME_START_X;
const float DY = getPosY() - FRAME_START_Y;
if (DY < 0.0F) {
facing_ = Facing::NORTH;
} else if (DY > 0.0F) {
facing_ = Facing::SOUTH;
} else if (DX < 0.0F) {
facing_ = Facing::WEST;
} else if (DX > 0.0F) {
facing_ = Facing::EAST;
}
}
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) {
// Gate d'immunitat amb bypass per a HOLE: la caiguda per agujero NO
// és dany convencional sinó "sortida lògica del món", així que els
// forats han de matar fins i tot el jugador immune. Qualsevol altra
// causa (mòmia letal via `player_killed`, etc.) queda neutralitzada
// mentre dura la finestra de gràcia.
if (cause != DeathCause::HOLE && isImmune()) { return; }
// 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 (!cfg_.hurt.sound_key.empty() && Audio::get() != nullptr) {
Audio::get()->playSound(cfg_.hurt.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>(cfg_.death.respawn_delay_ms);
}
if (death_animation_complete_) {
respawn_remaining_ms_ -= delta_time * 1000.0F;
if (respawn_remaining_ms_ <= 0.0F) {
respawnFromDying();
}
}
}
void Player::resetForJoin() {
lives_remaining_ = cfg_.death.initial_lives;
score_ = 0;
treasures_ = 0;
// Publica l'estat inicial perquè el HUD reompla el bloc en el moment
// de fer join (no només quan el comptador canvia després). Sense açò
// el HUD es quedaria amb la caché prèvia fins al primer event.
publishStatsChanged();
}
void Player::publishStatsChanged() {
if (bus_ == nullptr) { return; }
bus_->publish(Event{
.name = "player_stats_changed",
.payload = PlayerStatsPayload{
.lives = lives_remaining_,
.score = score_,
.treasures = treasures_,
},
.player_id = id_,
});
}
void Player::addScore(int delta) {
if (delta == 0) { return; }
score_ += delta;
publishStatsChanged();
}
void Player::addTreasures(int delta) {
if (delta == 0) { return; }
treasures_ += delta;
publishStatsChanged();
}
void Player::respawnFromDying() {
// Vides: decreix una unitat. Si el comptador arriba a 0 el jugador
// surt de partida (DISABLED) i la GameScene/HUD reaccionen via bus
// — això habilita el flux de "join in progress" (l'altre jugador
// pot pulsar START per entrar) i obrirà el camí al CONTINUE? +
// GAME OVER en una fase posterior.
--lives_remaining_;
death_animation_complete_ = false;
respawn_remaining_ms_ = 0.0F;
// Notifica al HUD del nou comptador de vides abans de qualsevol
// transició d'estat. Aquesta crida tanca el bucle de qualsevol via
// de mort (mòmia, traps, rolling rock, FORAT…): totes acaben aquí, i
// ningú més ha de recordar publicar res. Era exactament el punt que
// s'oblidava al camí del forat.
publishStatsChanged();
if (lives_remaining_ <= 0) {
disable();
if (bus_ != nullptr) {
bus_->publish(Event{
.name = "player_disabled",
.payload = std::monostate{},
.player_id = id_,
});
}
return;
}
// 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_),
});
}
// Blink d'immunitat: cada `blink_period_ms` ms, el render alterna
// entre la paleta normal i la paleta plana. La fase es deriva del
// timer restant — al spawn (timer = duration) toca paleta plana
// perquè el feedback sigui immediat. La Surface pot ser compartida
// entre dos jugadors del mateix personatge: com que el render del
// joc és seqüencial, el save/restore al voltant del render del
// sprite és segur — quan acaba aquesta funció, la Surface torna a
// tenir la paleta normal i la pròxima crida pot fer el seu propi
// swap independentment.
const bool BLINK_ON =
immunity_remaining_ms_ > 0.0F &&
cfg_.immunity.blink_period_ms > 0 &&
!cfg_.immunity.palette_key.empty() &&
surface_ != nullptr &&
(static_cast<int>(immunity_remaining_ms_) / cfg_.immunity.blink_period_ms) % 2 == 0;
if (BLINK_ON) {
surface_->setPalette(Resource::Cache::get().getPalette(cfg_.immunity.palette_key));
}
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 (BLINK_ON) { surface_->setPalette(normal_palette_); }
if (CLIP_HOLE) { Rendering::popClip(); }
}
auto Player::getLightCenter() const -> SDL_FPoint {
// Sprite top-left = (pos_.x, pos_.y). El render desplaça verticalment
// `jump_z_` píxels durant el salt; aplicar el mateix offset aquí fa
// que la llum "salti" amb el jugador en lloc de quedar-se al sòl.
// Sumem també l'offset estàtic configurat al YAML del personatge.
return {
.x = getPosX() + (static_cast<float>(cfg_.sprite_w) * 0.5F) + static_cast<float>(cfg_.light_offset_x),
.y = getPosY() - jump_z_ + (static_cast<float>(cfg_.sprite_h) * 0.5F) + static_cast<float>(cfg_.light_offset_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; }
// 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; }
// `height_factor`: 0 al terra → fotograma més gran, 1 al pic → més
// petit. Si el `cfg_.jump` té un divisor zero (cas degenerat) caem
// a 0 per no propagar nan al renderer.
const auto PEAK = (cfg_.jump.gravity_px_per_s2 > 0.0F)
? (cfg_.jump.impulse_px_per_s * cfg_.jump.impulse_px_per_s) / (2.0F * cfg_.jump.gravity_px_per_s2)
: 0.0F;
const float HEIGHT_FACTOR = (PEAK > 0.0F) ? std::clamp(jump_z_ / PEAK, 0.0F, 1.0F) : 0.0F;
const auto CENTER_X = static_cast<int>(getPosX() + (static_cast<float>(cfg_.sprite_w) * 0.5F));
const auto FEET_Y = static_cast<int>(getGroundFeetY());
Effects::Shadow::render(CENTER_X, FEET_Y, HEIGHT_FACTOR);
}
// 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>(cfg_.whip.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 - cfg_.collision_x_offset);
const auto MAX_X = static_cast<float>(
map_.playfield_x + map_.playfield_w - cfg_.collision_x_offset - cfg_.collision_size);
auto min_y = static_cast<float>(map_.playfield_y - cfg_.collision_y_offset);
const auto MAX_Y = static_cast<float>(
map_.playfield_y + map_.playfield_h - cfg_.collision_y_offset - cfg_.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>(cfg_.collision_x_offset);
const float BASE_RIGHT = BASE_X + static_cast<float>(cfg_.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 - cfg_.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);
// Immunitat post-spawn: armem la finestra de gràcia amb la durada del
// YAML. Cobreix el join mid-partida (`joinPlayer` → `spawnFromAir` →
// `respawn`) i el respawn post-mort (`respawnFromDying` → `respawn`).
// L'spawn inicial de ROOM_INTRO també hi passa, però `triggerPlayerSpawn`
// crida `setImmunity(0)` immediatament després per cancel·lar-la (els
// enemics ja estan apareixent i el jugador no ha de tindre gràcia).
// Si el bloc `immunity:` manca al YAML, `duration_ms` és 0 i el timer
// queda a 0 — sense gràcia, comportament idèntic al d'abans.
setImmunity(cfg_.immunity.duration_ms);
}
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 + cfg_.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>(cfg_.hit_size) * 0.5F;
setPosX(center_x - static_cast<float>(cfg_.hit_x_offset) - HIT_HALF);
setPosY(center_y - static_cast<float>(cfg_.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>(cfg_.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>(cfg_.collision_x_offset),
.y = getPosY() + static_cast<float>(cfg_.collision_y_offset),
.w = static_cast<float>(cfg_.collision_size),
.h = static_cast<float>(cfg_.collision_size),
};
}
auto Player::getHitBox() const -> SDL_FRect {
return {
.x = getPosX() + static_cast<float>(cfg_.hit_x_offset),
.y = getPosY() + static_cast<float>(cfg_.hit_y_offset),
.w = static_cast<float>(cfg_.hit_size),
.h = static_cast<float>(cfg_.hit_size),
};
}
auto Player::hitBoxFromBase(const SDL_FRect& base) const -> 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 = (cfg_.collision_size - cfg_.hit_size) / 2;
return {
.x = base.x + static_cast<float>(INSET),
.y = base.y + static_cast<float>(INSET),
.w = static_cast<float>(cfg_.hit_size),
.h = static_cast<float>(cfg_.hit_size),
};
}
void Player::snapTowardsLane(
float delta_time,
float target_lane,
float (Player::*get_pos)() const,
void (Player::*set_pos)(float)) {
const auto THRESHOLD = static_cast<float>(cfg_.collision_size) / 2.0F;
const float DELTA = target_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; }
// Si el tweak de col·lisió entre jugadors està desactivat, mai hi
// ha res a empentar — els jugadors són intangibles entre si i el
// snap pot procedir lliurement sense desplaçar ningú.
if (!GameplayConfig::get().playersCollide()) { 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;
}
auto Player::wouldHitOuterWall(float dx, float dy) const -> bool {
SDL_FRect probe = getBaseBox();
probe.x += dx;
probe.y += dy;
if (Map::insidePlayfield(probe)) { return false; }
// Excepció: corridor de la porta OBERTA quan l'usuari prem cap
// amunt (dy < 0). No demanem que la probe ja estigui dins del
// corridor — encara no ho està, perquè el snap és justament qui
// ha d'aliniar-la. En lloc d'això comprovem si la base box està
// prou prop del centre del corridor com per a que el snap
// (mateix THRESHOLD = cfg_.collision_size/2) puga completar-lo.
if (Map::door_open && dy < 0.0F && map_.door_corridor_w > 0) {
const float CORRIDOR_TARGET = static_cast<float>(map_.door_corridor_x) + (static_cast<float>(map_.door_corridor_w - cfg_.collision_size) / 2.0F);
const auto THRESHOLD = static_cast<float>(cfg_.collision_size) / 2.0F;
if (std::abs(CORRIDOR_TARGET - probe.x) <= THRESHOLD) { return false; }
}
return true;
}
void Player::snapToHorizontalLane(float delta_time) {
// Si el moviment principal del frame (eix X) està blocat per un mur
// extern, no fem snap perpendicular: l'usuari està picant contra el
// mur i no té sentit arrossegar-lo a un carril. Step probe d'1 px.
if (intended_vx_ != 0.0F && wouldHitOuterWall(std::copysign(1.0F, intended_vx_), 0.0F)) {
return;
}
const auto BASE_Y = static_cast<float>(map_.playfield_y - cfg_.collision_y_offset);
const auto PITCH_Y = static_cast<float>(map_.pitch_y);
const int IDX = std::clamp(
static_cast<int>(std::round((getPosY() - BASE_Y) / PITCH_Y)),
0,
map_.grid_rows);
const float LANE_REG = BASE_Y + (static_cast<float>(IDX) * PITCH_Y);
snapTowardsLane(delta_time, LANE_REG, &Player::getPosY, &Player::setPosY);
}
void Player::snapToVerticalLane(float delta_time) {
// Mateixa idea que la versió horitzontal: si el moviment principal
// (eix Y) està blocat per un mur extern, no aliniem en X. La
// excepció del corridor de la porta hi aplica naturalment dins
// `wouldHitOuterWall` (només quan és OBERT).
if (intended_vy_ != 0.0F && wouldHitOuterWall(0.0F, std::copysign(1.0F, intended_vy_))) {
return;
}
// Candidat 1: lane regular del laberint.
const auto BASE_X = static_cast<float>(map_.playfield_x - cfg_.collision_x_offset);
const auto PITCH_X = static_cast<float>(map_.pitch_x);
const int IDX = std::clamp(
static_cast<int>(std::round((getPosX() - BASE_X) / PITCH_X)),
0,
map_.grid_cols);
const float LANE_REG = BASE_X + (static_cast<float>(IDX) * PITCH_X);
// Candidat 2: corridor de la porta. NOMÉS si la porta està oberta —
// si està tancada no té sentit arrossegar el jugador cap a un
// forat per on no pot passar.
float best_lane = LANE_REG;
if (Map::door_open && map_.door_corridor_w > 0) {
const float CORRIDOR_CENTER = static_cast<float>(map_.door_corridor_x) + (static_cast<float>(map_.door_corridor_w - cfg_.collision_size) / 2.0F);
const float LANE_DOOR = CORRIDOR_CENTER - static_cast<float>(cfg_.collision_x_offset);
const float DELTA_REG = LANE_REG - getPosX();
const float DELTA_DOOR = LANE_DOOR - getPosX();
if (std::abs(DELTA_DOOR) < std::abs(DELTA_REG)) { best_lane = LANE_DOOR; }
}
snapTowardsLane(delta_time, best_lane, &Player::getPosX, &Player::setPosX);
}