1973 lines
86 KiB
C++
1973 lines
86 KiB
C++
#include "game/entities/player.hpp"
|
||
|
||
#include <SDL3/SDL.h>
|
||
|
||
#include <algorithm>
|
||
#include <array>
|
||
#include <cmath>
|
||
#include <stdexcept>
|
||
#include <utility>
|
||
|
||
#include "core/audio/audio.hpp"
|
||
#include "core/input/gamepad_bindings.hpp"
|
||
#include "core/input/gamepad_manager.hpp"
|
||
#include "core/input/input_state.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;
|
||
|
||
// Llegeix la mida del frame des del bloc `sprite: { path, size: [w, h] }`
|
||
// (mateix format que la resta de plantilles d'entitat, vegeu
|
||
// AnimatedSprite). Extracció per mantenir `Player::loadFrom` sota el
|
||
// llindar de complexitat cognitiva (25).
|
||
void readSpriteSize(const fkyaml::node& yaml, const std::string& yaml_path, int& w, int& h) {
|
||
if (!yaml.contains("sprite") || !yaml["sprite"].contains("size")) {
|
||
throw std::runtime_error("Player: manca `sprite.size` a " + yaml_path);
|
||
}
|
||
const auto& size_node = yaml["sprite"]["size"];
|
||
w = size_node[0].get_value<int>();
|
||
h = size_node[1].get_value<int>();
|
||
}
|
||
|
||
// Acumula la contribució del mando assignat al jugador (cruceta +
|
||
// stick esquerre) sobre els acumuladors de velocitat. Extracció
|
||
// d'`update()` per a mantenir la complexitat cognitiva sota llindar
|
||
// (threshold 25). L'assignació mando↔jugador viu al `GamepadManager`
|
||
// i la persisteix Options; si aquest jugador no en té cap assignat,
|
||
// la funció és un no-op (sols es mou amb el teclat).
|
||
void accumulateGamepadMovement(int player_id, float& vx, float& vy) {
|
||
SDL_Gamepad* pad = GamepadManager::get().getForPlayer(player_id);
|
||
if (pad == nullptr) { return; }
|
||
if (InputState::getGamepadButton(pad, SDL_GAMEPAD_BUTTON_DPAD_LEFT)) { vx -= SPEED_PX_PER_S; }
|
||
if (InputState::getGamepadButton(pad, SDL_GAMEPAD_BUTTON_DPAD_RIGHT)) { vx += SPEED_PX_PER_S; }
|
||
if (InputState::getGamepadButton(pad, SDL_GAMEPAD_BUTTON_DPAD_UP)) { vy -= SPEED_PX_PER_S; }
|
||
if (InputState::getGamepadButton(pad, SDL_GAMEPAD_BUTTON_DPAD_DOWN)) { vy += SPEED_PX_PER_S; }
|
||
const Sint16 LX = InputState::getGamepadAxis(pad, SDL_GAMEPAD_AXIS_LEFTX);
|
||
const Sint16 LY = InputState::getGamepadAxis(pad, SDL_GAMEPAD_AXIS_LEFTY);
|
||
if (LX < -STICK_DEADZONE) { vx -= SPEED_PX_PER_S; }
|
||
if (LX > STICK_DEADZONE) { vx += SPEED_PX_PER_S; }
|
||
if (LY < -STICK_DEADZONE) { vy -= SPEED_PX_PER_S; }
|
||
if (LY > STICK_DEADZONE) { vy += SPEED_PX_PER_S; }
|
||
}
|
||
|
||
} // namespace
|
||
|
||
Player::TurnConfig Player::turn_config;
|
||
Player::InertiaConfig Player::inertia_config;
|
||
Player::JumpConfig Player::jump_config;
|
||
Player::HurtConfig Player::hurt_config;
|
||
Player::WhipConfig Player::whip_config;
|
||
Player::DeathConfig Player::death_config;
|
||
Player::ImmunityConfig Player::immunity_config;
|
||
|
||
void Player::loadFrom(const std::string& yaml_path) {
|
||
auto bytes = Resource::Helper::loadFile(yaml_path);
|
||
if (bytes.empty()) {
|
||
throw std::runtime_error("Player: no s'ha pogut obrir " + yaml_path);
|
||
}
|
||
const auto* begin = reinterpret_cast<const char*>(bytes.data());
|
||
const auto* end = begin + bytes.size();
|
||
auto yaml = fkyaml::node::deserialize(begin, end);
|
||
|
||
readSpriteSize(yaml, yaml_path, sprite_w, sprite_h);
|
||
|
||
readCollisionBlock(yaml, yaml_path);
|
||
|
||
// Bloc opcional `inertia:` — si manca, `brake_time_ms` queda a 0 i
|
||
// la inèrcia de frenada no s'activa (parada instantània com abans).
|
||
inertia_config = InertiaConfig{};
|
||
if (yaml.contains("inertia")) {
|
||
const auto& i = yaml["inertia"];
|
||
if (i.contains("brake_time_ms")) { inertia_config.brake_time_ms = i["brake_time_ms"].get_value<int>(); }
|
||
if (i.contains("threshold_ms")) { inertia_config.threshold_ms = i["threshold_ms"].get_value<int>(); }
|
||
}
|
||
|
||
// Bloc opcional `turn:` — si manca, `duration_ms` queda a 0 i la
|
||
// mecànica del gir de 180° no s'activa (canvis de direcció instantanis).
|
||
turn_config = TurnConfig{};
|
||
if (yaml.contains("turn")) {
|
||
const auto& t = yaml["turn"];
|
||
if (t.contains("duration_ms")) { turn_config.duration_ms = t["duration_ms"].get_value<int>(); }
|
||
if (t.contains("transitions")) {
|
||
const auto& tr = t["transitions"];
|
||
if (tr.contains("west_to_east")) { turn_config.w_to_e = cardinalFromString(tr["west_to_east"].get_value<std::string>()); }
|
||
if (tr.contains("east_to_west")) { turn_config.e_to_w = cardinalFromString(tr["east_to_west"].get_value<std::string>()); }
|
||
if (tr.contains("north_to_south")) { turn_config.n_to_s = cardinalFromString(tr["north_to_south"].get_value<std::string>()); }
|
||
if (tr.contains("south_to_north")) { turn_config.s_to_n = cardinalFromString(tr["south_to_north"].get_value<std::string>()); }
|
||
}
|
||
}
|
||
|
||
// Bloc opcional `jump:` — si manca, `impulse_px_per_s` queda a 0 i el
|
||
// botó de salt no fa res. Extret a un mètode estàtic per acotar la
|
||
// complexitat cognitiva de `loadFrom` sota el llindar de clang-tidy.
|
||
readJumpBlock(yaml);
|
||
|
||
// Bloc opcional `hurt:` — estat post-impacte. Si manca o el speed
|
||
// és 0, l'estat HURT mai s'activa (cas degenerat).
|
||
readHurtBlock(yaml);
|
||
|
||
// Bloc opcional `whip:` — paràmetres del látigo. Si manca, el
|
||
// jugador no atacarà efectivament (ATTACKING dura 0 ms i el flanc
|
||
// d'input es perd).
|
||
readWhipBlock(yaml);
|
||
|
||
// Bloc opcional `death:` — pausa de respawn i comptador inicial de
|
||
// vides. Si manca, el respawn és immediat i les vides queden a 3
|
||
// per defecte (no és un valor crític: avui no hi ha game over).
|
||
readDeathBlock(yaml);
|
||
|
||
// Bloc opcional `immunity:` — finestra de gràcia post-spawn. Si
|
||
// `duration_ms` és 0 (o el bloc manca) la mecànica queda
|
||
// desactivada i els hazards toquen al primer frame, com abans.
|
||
readImmunityBlock(yaml);
|
||
|
||
// Bloc opcional `light:` — radi i flicker. Extret a un mètode per
|
||
// mantenir `loadFrom` sota el llindar de complexitat de clang-tidy.
|
||
readLightBlock(yaml);
|
||
}
|
||
|
||
void Player::readLightBlock(const fkyaml::node& yaml) {
|
||
light_radius = 0;
|
||
light_flicker = false;
|
||
light_color_r = 255;
|
||
light_color_g = 255;
|
||
light_color_b = 255;
|
||
light_offset_x = 0;
|
||
light_offset_y = 0;
|
||
// Punt de partida del flicker: el global de lighting.yaml. El bloc
|
||
// opcional `flicker_params:` del personatge sobreescriu camps concrets.
|
||
flicker_params = Lighting::flickerConfig();
|
||
if (!yaml.contains("light")) { return; }
|
||
const auto& l = yaml["light"];
|
||
if (l.contains("radius")) { light_radius = l["radius"].get_value<int>(); }
|
||
if (l.contains("flicker")) { light_flicker = l["flicker"].get_value<bool>(); }
|
||
// `color: [r, g, b]` opcional, bytes 0..255. Si manca, blanc (sense
|
||
// tint) — comportament idèntic al mode subtractive de hui.
|
||
if (l.contains("color")) {
|
||
const auto& c = l["color"];
|
||
if (c.is_sequence() && c.size() == 3) {
|
||
light_color_r = static_cast<std::uint8_t>(std::clamp(c[0].get_value<int>(), 0, 255));
|
||
light_color_g = static_cast<std::uint8_t>(std::clamp(c[1].get_value<int>(), 0, 255));
|
||
light_color_b = static_cast<std::uint8_t>(std::clamp(c[2].get_value<int>(), 0, 255));
|
||
}
|
||
}
|
||
// `offset: [x, y]` opcional, píxels enters. Default (0, 0) → centre
|
||
// del sprite. Y creix cap avall, així que `y: -3` puja la llum 3 px.
|
||
if (l.contains("offset")) {
|
||
const auto& o = l["offset"];
|
||
if (o.is_sequence() && o.size() == 2) {
|
||
light_offset_x = o[0].get_value<int>();
|
||
light_offset_y = o[1].get_value<int>();
|
||
}
|
||
}
|
||
// `flicker_params: { ... }` opcional. Qualsevol dels 4 camps absent
|
||
// conserva el valor heretat del global (vegeu inicialització a sobre).
|
||
if (l.contains("flicker_params")) {
|
||
const auto& fp = l["flicker_params"];
|
||
if (fp.contains("wave_amplitude_ratio")) { flicker_params.wave_amplitude_ratio = fp["wave_amplitude_ratio"].get_value<float>(); }
|
||
if (fp.contains("wave_frequency_hz")) { flicker_params.wave_frequency_hz = fp["wave_frequency_hz"].get_value<float>(); }
|
||
if (fp.contains("noise_amplitude_ratio")) { flicker_params.noise_amplitude_ratio = fp["noise_amplitude_ratio"].get_value<float>(); }
|
||
if (fp.contains("noise_frequency_hz")) { flicker_params.noise_frequency_hz = fp["noise_frequency_hz"].get_value<float>(); }
|
||
}
|
||
}
|
||
|
||
void Player::readCollisionBlock(const fkyaml::node& yaml, const std::string& yaml_path) {
|
||
if (!yaml.contains("collision")) {
|
||
throw std::runtime_error("Player: manca bloc `collision:` a " + yaml_path);
|
||
}
|
||
const auto& c = yaml["collision"];
|
||
collision_size = c["size"].get_value<int>();
|
||
base_size = c["base_size"].get_value<int>();
|
||
|
||
// Col·lisió centrada en el quadrat inferior del sprite. Si la caixa és
|
||
// més gran que la base (cas habitual, 16 vs 12) el padding és simètric
|
||
// a cada costat; si són iguals, padding=0 i la caixa coincideix amb la
|
||
// base sense offsets.
|
||
collision_padding = (collision_size - base_size) / 2;
|
||
collision_x_offset = -collision_padding;
|
||
collision_y_offset = (sprite_h - base_size) - collision_padding;
|
||
|
||
// Hit-box opcional (actor-actor). Si no hi és, s'iguala a la base
|
||
// per preservar compatibilitat amb YAMLs antics. Concèntrica amb la
|
||
// caixa de col·lisió: offset interior = (collision_size - hit_size) / 2.
|
||
hit_size = c.contains("hit_size") ? c["hit_size"].get_value<int>() : collision_size;
|
||
const int HIT_INSET = (collision_size - hit_size) / 2;
|
||
hit_x_offset = collision_x_offset + HIT_INSET;
|
||
hit_y_offset = collision_y_offset + HIT_INSET;
|
||
|
||
// Alçada del cos, llegida del mateix bloc per coherència amb la
|
||
// resta d'entitats (mummy, sarcophagus, etc.). 0 = sense filtre per
|
||
// altura — el salt no esquiva l'altre jugador.
|
||
body_height = c.contains("height") ? c["height"].get_value<int>() : 0;
|
||
}
|
||
|
||
void Player::readJumpBlock(const fkyaml::node& yaml) {
|
||
jump_config = JumpConfig{};
|
||
if (!yaml.contains("jump")) { return; }
|
||
const auto& j = yaml["jump"];
|
||
if (j.contains("impulse")) { jump_config.impulse_px_per_s = j["impulse"].get_value<float>(); }
|
||
if (j.contains("gravity")) { jump_config.gravity_px_per_s2 = j["gravity"].get_value<float>(); }
|
||
if (j.contains("landing_stun_ms")) { jump_config.landing_stun_ms = j["landing_stun_ms"].get_value<int>(); }
|
||
// Sons opcionals: el nom del fitxer (relatiu a Defaults::Paths::AUDIO_SOUNDS_DIR)
|
||
// val tant de clau al Resource::Cache com de basename per a la càrrega.
|
||
// Si el camp manca, la clau queda buida i `playSound` no es crida.
|
||
if (j.contains("sound")) {
|
||
jump_config.sound_key = j["sound"].get_value<std::string>();
|
||
Resource::Cache::get().loadSound(jump_config.sound_key, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + jump_config.sound_key);
|
||
}
|
||
if (j.contains("land_sound")) {
|
||
jump_config.land_sound_key = j["land_sound"].get_value<std::string>();
|
||
Resource::Cache::get().loadSound(jump_config.land_sound_key, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + jump_config.land_sound_key);
|
||
}
|
||
}
|
||
|
||
void Player::readHurtBlock(const fkyaml::node& yaml) {
|
||
hurt_config = HurtConfig{};
|
||
if (!yaml.contains("hurt")) { return; }
|
||
const auto& h = yaml["hurt"];
|
||
if (h.contains("speed")) { hurt_config.initial_speed_px_per_s = h["speed"].get_value<float>(); }
|
||
if (h.contains("brake_time_ms")) { hurt_config.brake_time_ms = h["brake_time_ms"].get_value<int>(); }
|
||
// So d'entrada a HURT — mateix patró que `jump.sound` i `whip.sound`.
|
||
if (h.contains("sound")) {
|
||
hurt_config.sound_key = h["sound"].get_value<std::string>();
|
||
Resource::Cache::get().loadSound(hurt_config.sound_key, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + hurt_config.sound_key);
|
||
}
|
||
}
|
||
|
||
void Player::readDeathBlock(const fkyaml::node& yaml) {
|
||
death_config = DeathConfig{};
|
||
if (!yaml.contains("death")) { return; }
|
||
const auto& d = yaml["death"];
|
||
if (d.contains("respawn_delay_ms")) { death_config.respawn_delay_ms = d["respawn_delay_ms"].get_value<int>(); }
|
||
if (d.contains("lives")) { death_config.initial_lives = d["lives"].get_value<int>(); }
|
||
}
|
||
|
||
void Player::readImmunityBlock(const fkyaml::node& yaml) {
|
||
immunity_config = ImmunityConfig{};
|
||
if (!yaml.contains("immunity")) { return; }
|
||
const auto& im = yaml["immunity"];
|
||
if (im.contains("duration_ms")) { immunity_config.duration_ms = im["duration_ms"].get_value<float>(); }
|
||
if (im.contains("blink_period_ms")) { immunity_config.blink_period_ms = im["blink_period_ms"].get_value<int>(); }
|
||
// Paleta del blink: el basename val tant de clau al Resource::Cache
|
||
// com de path (`Defaults::Paths::PLAYER_DIR + key + .pal`). Mateix patró
|
||
// que els sons.
|
||
if (im.contains("palette")) {
|
||
immunity_config.palette_key = im["palette"].get_value<std::string>();
|
||
Resource::Cache::get().loadPalette(immunity_config.palette_key, std::string{Defaults::Paths::PLAYER_DIR} + immunity_config.palette_key + ".pal");
|
||
}
|
||
}
|
||
|
||
void Player::readWhipBlock(const fkyaml::node& yaml) {
|
||
whip_config = WhipConfig{};
|
||
if (!yaml.contains("whip")) { return; }
|
||
const auto& w = yaml["whip"];
|
||
if (w.contains("range_tiles")) { whip_config.range_tiles = w["range_tiles"].get_value<float>(); }
|
||
if (w.contains("swing_ms")) { whip_config.swing_ms = w["swing_ms"].get_value<int>(); }
|
||
if (w.contains("cooldown_ms")) { whip_config.cooldown_ms = w["cooldown_ms"].get_value<int>(); }
|
||
if (w.contains("damage")) { whip_config.damage = w["damage"].get_value<int>(); }
|
||
// So del swing: mateix patró que `jump.sound` — basename val de
|
||
// clau al cache i de path. Si manca, queda buit i no es dispara.
|
||
if (w.contains("sound")) {
|
||
whip_config.sound_key = w["sound"].get_value<std::string>();
|
||
Resource::Cache::get().loadSound(whip_config.sound_key, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + whip_config.sound_key);
|
||
}
|
||
}
|
||
|
||
void Player::applyCardinal(Cardinal c) {
|
||
// El flip del sprite el governa l'animació al motor: només cal posar
|
||
// el `facing_` que tocaria; `animationName()` ja resol el sufix
|
||
// (`_west` per Cardinal::WEST) i el YAML hi declara `flip_h: true`.
|
||
switch (c) {
|
||
case Cardinal::NORTH:
|
||
facing_ = Facing::NORTH;
|
||
break;
|
||
case Cardinal::SOUTH:
|
||
facing_ = Facing::SOUTH;
|
||
break;
|
||
case Cardinal::EAST:
|
||
facing_ = Facing::EAST;
|
||
break;
|
||
case Cardinal::WEST:
|
||
facing_ = Facing::WEST;
|
||
break;
|
||
case Cardinal::NONE:
|
||
break;
|
||
}
|
||
}
|
||
|
||
auto Player::cardinalFromInput(float vx, float vy) -> Cardinal {
|
||
// Mateixa precedència que la lògica original: l'horitzontal preval
|
||
// sobre el vertical (els diagonals s'interpreten com a laterals).
|
||
if (vx > 0.0F) { return Cardinal::EAST; }
|
||
if (vx < 0.0F) { return Cardinal::WEST; }
|
||
if (vy < 0.0F) { return Cardinal::NORTH; }
|
||
if (vy > 0.0F) { return Cardinal::SOUTH; }
|
||
return Cardinal::NONE;
|
||
}
|
||
|
||
auto Player::cardinalFromString(const std::string& s) -> Cardinal {
|
||
if (s == "north") { return Cardinal::NORTH; }
|
||
if (s == "south") { return Cardinal::SOUTH; }
|
||
if (s == "east") { return Cardinal::EAST; }
|
||
if (s == "west") { return Cardinal::WEST; }
|
||
throw std::runtime_error("Player: cardinal desconegut '" + s + "' al bloc turn.transitions");
|
||
}
|
||
|
||
auto Player::opposite(Cardinal c) -> Cardinal {
|
||
switch (c) {
|
||
case Cardinal::NORTH:
|
||
return Cardinal::SOUTH;
|
||
case Cardinal::SOUTH:
|
||
return Cardinal::NORTH;
|
||
case Cardinal::EAST:
|
||
return Cardinal::WEST;
|
||
case Cardinal::WEST:
|
||
return Cardinal::EAST;
|
||
case Cardinal::NONE:
|
||
return Cardinal::NONE;
|
||
}
|
||
return Cardinal::NONE;
|
||
}
|
||
|
||
auto Player::intermediateFor(Cardinal target) -> Cardinal {
|
||
switch (target) {
|
||
case Cardinal::EAST:
|
||
return turn_config.w_to_e;
|
||
case Cardinal::WEST:
|
||
return turn_config.e_to_w;
|
||
case Cardinal::SOUTH:
|
||
return turn_config.n_to_s;
|
||
case Cardinal::NORTH:
|
||
return turn_config.s_to_n;
|
||
case Cardinal::NONE:
|
||
return Cardinal::NONE;
|
||
}
|
||
return Cardinal::NONE;
|
||
}
|
||
|
||
void Player::updateFacingAndTurn(float delta_time, float vx, float vy) {
|
||
// Cardinal d'INPUT cru. Important: la detecció del gir de 180° s'ha
|
||
// de basar en l'input (intent del jugador), no en la velocitat
|
||
// resultant. Mentre no hi haja inèrcia, vx/vy són directament l'input
|
||
// i serveixen com a tal; quan s'introduïsca, cal preservar aquesta
|
||
// separació (calcular el cardinal a partir de les tecles/gamepad
|
||
// crus, no de la velocitat suavitzada).
|
||
const Cardinal INPUT_CARD = cardinalFromInput(vx, vy);
|
||
|
||
// Si hi ha un gir en curs, descomptem el timer i mantenim el facing
|
||
// forçat al cardinal intermedi. Quan s'esgota, apliquem el cardinal
|
||
// pendent. El moviment físic continua sense alterar-se (vx/vy ja
|
||
// s'han enviat a setVel al caller).
|
||
if (turning_remaining_ms_ > 0.0F) {
|
||
turning_remaining_ms_ -= delta_time * 1000.0F;
|
||
if (turning_remaining_ms_ <= 0.0F) {
|
||
turning_remaining_ms_ = 0.0F;
|
||
applyCardinal(pending_cardinal_);
|
||
pending_cardinal_ = Cardinal::NONE;
|
||
intermediate_cardinal_ = Cardinal::NONE;
|
||
} else {
|
||
applyCardinal(intermediate_cardinal_);
|
||
}
|
||
} else {
|
||
// Detecció del gir: `last_input_cardinal_` és l'últim cardinal
|
||
// d'input no nul (es preserva entre releases). Així, el primer
|
||
// input des de l'estat idle no dispara cap gir — només dispara
|
||
// quan el jugador "canvia d'opinió" prement la direcció oposada
|
||
// a la prèvia. Requereix també que el bloc `turn:` del YAML
|
||
// estiga activat (`duration_ms > 0`).
|
||
const bool TRIGGER_TURN =
|
||
(turn_config.duration_ms > 0) && INPUT_CARD != Cardinal::NONE && last_input_cardinal_ != Cardinal::NONE && INPUT_CARD == opposite(last_input_cardinal_);
|
||
if (TRIGGER_TURN) {
|
||
pending_cardinal_ = INPUT_CARD;
|
||
intermediate_cardinal_ = intermediateFor(INPUT_CARD);
|
||
turning_remaining_ms_ = static_cast<float>(turn_config.duration_ms);
|
||
applyCardinal(intermediate_cardinal_);
|
||
} else if (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(const std::string& anim_key, int id, float x, float y, const Controls& controls, const Map::Info& map_info, int paint_tile_index)
|
||
: AnimatedSprite(Resource::Cache::get().getAnimationData(anim_key)),
|
||
id_(id),
|
||
controls_(controls),
|
||
map_(map_info),
|
||
paint_tile_index_(paint_tile_index) {
|
||
setPos(x, y);
|
||
// Postura inicial mirant cap al jugador (cara avall), no de costat:
|
||
// forcem facing_ a SOUTH i l'animació `stand_south` abans del primer
|
||
// frame d'input — el primer `update` la preservarà si l'usuari no
|
||
// prem cap tecla.
|
||
facing_ = Facing::SOUTH;
|
||
setCurrentAnimation("stand_south");
|
||
// Comptador d'intents inicialitzat amb el valor del YAML (default 3
|
||
// si el bloc `death:` manca). Fora del cicle DYING no toca a ningú.
|
||
lives_remaining_ = death_config.initial_lives;
|
||
// Spawn point inicial: la posició del ctor val per defecte; el
|
||
// GameScene el reescriu via `setSpawnPoint` quan canvia de sala.
|
||
spawn_x_ = x;
|
||
spawn_y_ = y;
|
||
// 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 + body_height] solapen. Així permetem el xoc
|
||
// mid-aire quan tots dos van un contra l'altre i estan a la
|
||
// mateixa franja d'alçada.
|
||
const SDL_FRect PROPOSED_HIT = hitBoxFromBase(box);
|
||
const bool MY_AIR = (state_ == State::AIRBORNE);
|
||
const bool MY_HURT = (state_ == State::HURT);
|
||
const float MY_Z = jump_z_;
|
||
const bool MY_DYING = (state_ == State::DYING);
|
||
return std::ranges::any_of(*all_players_, [this, &PROPOSED_HIT, MY_AIR, MY_HURT, MY_DYING, MY_Z](const Player* p) {
|
||
if (p == nullptr || p == this) { return false; }
|
||
if (p->isOutOfPlay()) { return false; }
|
||
// DYING és intangible per a PvP: el jugador mort no actua de
|
||
// mur, i un altre jugador en DYING no pot empentar tampoc.
|
||
if (MY_DYING || p->isDying()) { return false; }
|
||
if (MY_HURT || p->isHurt()) { return false; }
|
||
if (MY_AIR != p->isAirborne()) { return false; }
|
||
// Aquí els dos estats coincideixen. Si tots dos volen, el filtre
|
||
// per altura mira que els rangs verticals encara solapin; si no,
|
||
// estan a prou distància en Y "real" i no es toquen.
|
||
if (MY_AIR && body_height > 0 && std::fabs(MY_Z - p->jump_z_) >= static_cast<float>(body_height)) {
|
||
return false;
|
||
}
|
||
const auto OTHER = p->getHitBox();
|
||
return SDL_HasRectIntersectionFloat(&PROPOSED_HIT, &OTHER);
|
||
});
|
||
}
|
||
|
||
void Player::integrateAxis(float& phys, float input, float delta_time, float motion_time_ms, bool any_input) {
|
||
// Acceleració instantània: mentre hi ha input, la velocitat salta a
|
||
// ±SPEED sense transició. Cobreix també els canvis de direcció (un
|
||
// coast cap a la dreta passa a -SPEED immediatament si es prem esquerra).
|
||
if (input != 0.0F) {
|
||
phys = input;
|
||
return;
|
||
}
|
||
if (phys == 0.0F) { return; }
|
||
// Hi ha velocitat residual i aquest eix no té input. Tres camins:
|
||
// · Input a l'altre eix (gir de 90°): parada en sec — no arrosseguem
|
||
// en diagonal mentre l'usuari està premant una altra direcció.
|
||
// · Input solta del tot i llindar superat: inèrcia (decaiment lineal).
|
||
// · Input solta del tot i llindar NO superat: parada en sec (passets).
|
||
if (any_input) {
|
||
phys = 0.0F;
|
||
return;
|
||
}
|
||
const bool INERTIA_ACTIVE = (inertia_config.brake_time_ms > 0) && (motion_time_ms >= static_cast<float>(inertia_config.threshold_ms));
|
||
if (!INERTIA_ACTIVE) {
|
||
phys = 0.0F;
|
||
return;
|
||
}
|
||
const float DECEL = SPEED_PX_PER_S / (static_cast<float>(inertia_config.brake_time_ms) / 1000.0F);
|
||
const float STEP = DECEL * delta_time;
|
||
if (std::fabs(phys) <= STEP) {
|
||
phys = 0.0F;
|
||
} else {
|
||
phys -= std::copysign(STEP, phys);
|
||
}
|
||
}
|
||
|
||
// Pipeline d'estats i input separats; extracció a helpers empitjoraria la
|
||
// llegibilitat. La complexitat puja 1 per la guarda EXITED.
|
||
// NOLINTNEXTLINE(readability-function-cognitive-complexity)
|
||
void Player::tick(float delta_time) {
|
||
// EXITED/DISABLED: jugador fora del joc, no fa res. Cap input, cap
|
||
// física, cap bus event. Continua sent al vector de jugadors per
|
||
// simplicitat (no s'esborra) però queda completament inert fins
|
||
// que GameScene el reactiva (respawn / spawnFromAir).
|
||
if (isOutOfPlay()) { return; }
|
||
|
||
// 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);
|
||
}
|
||
|
||
// DYING: animació `death_spinning` + pausa de respawn. Ignorem tot
|
||
// l'input (ni moviment, ni salt, ni atac). El moviment queda
|
||
// congelat al lloc de la mort; `applyMovement` avança l'animació i
|
||
// detecta la fi per disparar el respawn. Mantenim els flancs de
|
||
// botó actualitzats perquè una tecla sostinguda no es comporte com
|
||
// a flanc nou en sortir del cicle.
|
||
if (state_ == State::DYING) {
|
||
(void)consumeJumpEdge();
|
||
(void)consumeAttackEdge();
|
||
input_vx_ = 0.0F;
|
||
input_vy_ = 0.0F;
|
||
intended_vx_ = 0.0F;
|
||
intended_vy_ = 0.0F;
|
||
motion_time_ms_ = 0.0F;
|
||
setVelX(0.0F);
|
||
setVelY(0.0F);
|
||
return;
|
||
}
|
||
|
||
// Reset per frame: el resolver del GameScene el tornarà a posar a
|
||
// true si aquest frame hi ha push actiu. Així quan el push acaba
|
||
// (un deixa d'empentar, l'altre comença a caminar), l'animació torna
|
||
// a governar-se per la velocitat real sense estat romanent.
|
||
being_pushed_ = false;
|
||
|
||
// En LANDING_STUN, descomptem el timer i ignorem qualsevol input
|
||
// (moviment i salt). Quan s'esgota, tornem a GROUNDED al pròxim
|
||
// frame. La velocitat queda a zero — després del stun hi tornarà
|
||
// a haver intenció si el jugador prem alguna tecla.
|
||
if (state_ == State::LANDING_STUN) {
|
||
landing_stun_remaining_ms_ -= delta_time * 1000.0F;
|
||
if (landing_stun_remaining_ms_ <= 0.0F) {
|
||
landing_stun_remaining_ms_ = 0.0F;
|
||
state_ = State::GROUNDED;
|
||
}
|
||
input_vx_ = 0.0F;
|
||
input_vy_ = 0.0F;
|
||
intended_vx_ = 0.0F;
|
||
intended_vy_ = 0.0F;
|
||
motion_time_ms_ = 0.0F;
|
||
setVelX(0.0F);
|
||
setVelY(0.0F);
|
||
// Mantenim els flancs del botó de salt actualitzats fins i tot
|
||
// durant el stun: així si l'usuari l'ha estat prement abans, no
|
||
// se'l consumeix com a salt nou en sortir del stun.
|
||
(void)consumeJumpEdge();
|
||
(void)consumeAttackEdge();
|
||
return;
|
||
}
|
||
|
||
// En AIRBORNE el jugador no llig input direccional: la velocitat
|
||
// horitzontal queda fixada al moment del despegue. Tampoc canvia
|
||
// facing. Sí actualitzem els flancs del botó perquè la propera
|
||
// pulsació quan torne a terra es detecte com a flanc nou.
|
||
if (state_ == State::AIRBORNE) {
|
||
(void)consumeJumpEdge();
|
||
(void)consumeAttackEdge();
|
||
setVelX(intended_vx_);
|
||
setVelY(intended_vy_);
|
||
return;
|
||
}
|
||
|
||
// En ATTACKING el jugador queda bloquejat tot el `swing_ms +
|
||
// cooldown_ms`: ni input direccional, ni salt, ni atac, ni canvi
|
||
// de facing. El timer descompta amb `delta_time * 1000` per
|
||
// mantenir la convenció de delta_ms de la resta d'estats temporals.
|
||
// Mantenim els flancs dels botons actualitzats perquè una
|
||
// tecla mantinguda no es comporte com a flanc nou en sortir.
|
||
if (state_ == State::ATTACKING) {
|
||
(void)consumeJumpEdge();
|
||
(void)consumeAttackEdge();
|
||
attacking_remaining_ms_ -= delta_time * 1000.0F;
|
||
if (attacking_remaining_ms_ <= 0.0F) {
|
||
attacking_remaining_ms_ = 0.0F;
|
||
state_ = State::GROUNDED;
|
||
}
|
||
input_vx_ = 0.0F;
|
||
input_vy_ = 0.0F;
|
||
intended_vx_ = 0.0F;
|
||
intended_vy_ = 0.0F;
|
||
motion_time_ms_ = 0.0F;
|
||
setVelX(0.0F);
|
||
setVelY(0.0F);
|
||
return;
|
||
}
|
||
|
||
// En HURT el jugador està projectat sense control: descomptem
|
||
// velocitat per inèrcia (decaiment lineal fins a 0). Quan arriba a
|
||
// zero, tornem a GROUNDED i el frame següent ja torna a llegir input.
|
||
// El moviment real l'aplica `applyMovement` (igual que els altres
|
||
// estats), que també gestiona el xoc contra murs.
|
||
if (state_ == State::HURT) {
|
||
(void)consumeJumpEdge();
|
||
(void)consumeAttackEdge();
|
||
const float DECEL = (hurt_config.brake_time_ms > 0)
|
||
? hurt_config.initial_speed_px_per_s / (static_cast<float>(hurt_config.brake_time_ms) / 1000.0F)
|
||
: 0.0F;
|
||
const float STEP = DECEL * delta_time;
|
||
auto decay = [STEP](float& v) {
|
||
if (std::fabs(v) <= STEP) {
|
||
v = 0.0F;
|
||
} else {
|
||
v -= std::copysign(STEP, v);
|
||
}
|
||
};
|
||
decay(intended_vx_);
|
||
decay(intended_vy_);
|
||
if (intended_vx_ == 0.0F && intended_vy_ == 0.0F) {
|
||
state_ = State::GROUNDED;
|
||
}
|
||
input_vx_ = 0.0F;
|
||
input_vy_ = 0.0F;
|
||
setVelX(intended_vx_);
|
||
setVelY(intended_vy_);
|
||
return;
|
||
}
|
||
|
||
// Refresca les vinculacions des d'Options en calent: així un remap fet
|
||
// al menú de servei té efecte immediatament, sense esperar al pròxim
|
||
// GameScene::onEnter. Replica el comportament d'AEE
|
||
// (`KeyRemap::update` que llig `Options::keys_game` cada frame).
|
||
refreshControlsFromOptions();
|
||
|
||
// Via `InputState` en lloc de `SDL_GetKeyboardState` directe: quan el
|
||
// service menu (o qualsevol UI modal) està obert, el wrapper retorna
|
||
// un buffer tot-a-zero perquè el jugador no es mogui mentre l'usuari
|
||
// navega el menú. Polling-blocked; veure `core/input/input_state.hpp`.
|
||
const bool* keys = InputState::getKeyboardState();
|
||
float vx = 0.0F;
|
||
float vy = 0.0F;
|
||
if (keys[controls_.left]) { vx -= SPEED_PX_PER_S; }
|
||
if (keys[controls_.right]) { vx += SPEED_PX_PER_S; }
|
||
if (keys[controls_.up]) { vy -= SPEED_PX_PER_S; }
|
||
if (keys[controls_.down]) { vy += SPEED_PX_PER_S; }
|
||
|
||
// Cruceta i stick esquerre del mando assignat (si n'hi ha). També
|
||
// respecten el bloqueig d'`InputState`, així que amb el menú obert
|
||
// els botons tornen false/0 i no contribueixen a la velocitat.
|
||
accumulateGamepadMovement(id_, vx, vy);
|
||
|
||
input_vx_ = vx;
|
||
input_vy_ = vy;
|
||
|
||
// Integració física per eix. `intended_v*` fa de memòria entre frames:
|
||
// si l'usuari té input, se sobreescriu a ±SPEED; si no, decau per
|
||
// inèrcia o es posa a 0. El `motion_time_ms_` que passem és el del
|
||
// final del frame anterior — serveix per decidir si la parada d'aquest
|
||
// frame és suau (llindar superat) o en sec. HAS_INPUT bloca la inèrcia
|
||
// en girs de 90° (un eix a 0 amb l'altre actiu no ha de "patinar").
|
||
const bool HAS_INPUT = (input_vx_ != 0.0F) || (input_vy_ != 0.0F);
|
||
integrateAxis(intended_vx_, input_vx_, delta_time, motion_time_ms_, HAS_INPUT);
|
||
integrateAxis(intended_vy_, input_vy_, delta_time, motion_time_ms_, HAS_INPUT);
|
||
|
||
// Actualitzem l'acumulador DESPRÉS de la integració. Creix mentre hi
|
||
// haja intenció (input) o moviment residual (coast); només es buida
|
||
// quan el jugador està genuïnament aturat (ni input ni velocitat). Així
|
||
// un canvi de direcció enmig del coast o un toc curt contra el coast
|
||
// no rearma mai el llindar.
|
||
const bool HAS_VELOCITY = (intended_vx_ != 0.0F) || (intended_vy_ != 0.0F);
|
||
if (HAS_INPUT || HAS_VELOCITY) {
|
||
motion_time_ms_ += delta_time * 1000.0F;
|
||
} else {
|
||
motion_time_ms_ = 0.0F;
|
||
}
|
||
|
||
setVelX(intended_vx_);
|
||
setVelY(intended_vy_);
|
||
|
||
// Important: el facing i la detecció de gir de 180° s'alimenten amb
|
||
// input CRU, no amb la velocitat física. Un coast sense input NO
|
||
// canvia el facing (la darrera orientació es preserva), i els girs
|
||
// només disparen amb intenció explícita del jugador.
|
||
updateFacingAndTurn(delta_time, input_vx_, input_vy_);
|
||
|
||
// Detecció de flanc del botó d'atac. Es processa ABANS del salt
|
||
// perquè ATTACKING i AIRBORNE són mútuament excloents i, davant un
|
||
// mateix-frame, l'atac té prioritat (millor castigar la pulsació
|
||
// doble per excés que perdre l'atac per un toc accidental al salt).
|
||
// En entrar a ATTACKING, descartem el flanc del salt per no que
|
||
// queda penjat per a quan es desbloque.
|
||
if (consumeAttackEdge() && whip_config.swing_ms > 0) {
|
||
enterAttacking(currentFacingCardinal());
|
||
(void)consumeJumpEdge();
|
||
return;
|
||
}
|
||
|
||
// Detecció de flanc del botó de salt. Es processa DESPRÉS d'aplicar
|
||
// el facing perquè el sprite del salt usa el facing del moment del
|
||
// despegue. `being_pushed_` no ho pot bloquejar ací (encara no s'ha
|
||
// marcat aquest frame, ho fa el resolver entre tick i applyMovement);
|
||
// ho gestionem deixant que el resolver vinga, i si en el moment del
|
||
// despegue (consumeJumpEdge → startJump) ja estem AIRBORNE, l'altre
|
||
// braç del switch no canvia res.
|
||
if (consumeJumpEdge() && jump_config.impulse_px_per_s > 0.0F) {
|
||
startJump();
|
||
}
|
||
}
|
||
|
||
void Player::refreshControlsFromOptions() {
|
||
if (id_ != 0 && id_ != 1) { return; }
|
||
const auto& kb = (id_ == 0) ? Options::controls().keyboard_p1
|
||
: Options::controls().keyboard_p2;
|
||
controls_.up = kb.up;
|
||
controls_.down = kb.down;
|
||
controls_.left = kb.left;
|
||
controls_.right = kb.right;
|
||
controls_.jump = kb.jump;
|
||
controls_.attack = kb.attack;
|
||
}
|
||
|
||
auto Player::consumeJumpEdge() -> bool {
|
||
// Edge-detection independent per teclat i mando: si qualsevol dels
|
||
// dos passa de no-premut a premut aquest frame, comptem com a salt.
|
||
// Sense detecció de flanc, mantenir la tecla provocaria saltar
|
||
// immediatament en aterrar.
|
||
const bool* keys = InputState::getKeyboardState();
|
||
const bool KEY_NOW = keys[controls_.jump];
|
||
bool edge = (KEY_NOW && !jump_held_keyboard_);
|
||
jump_held_keyboard_ = KEY_NOW;
|
||
|
||
SDL_Gamepad* pad = GamepadManager::get().getForPlayer(id_);
|
||
const auto& slot = (id_ == 0) ? Options::controls().gamepad_p1
|
||
: Options::controls().gamepad_p2;
|
||
const int JUMP_BTN = GamepadBindings::get().getButton(slot.name, slot.path, GamepadBindings::Action::JUMP);
|
||
const bool PAD_NOW = (pad != nullptr) && !InputState::isBlocked() &&
|
||
GamepadBindings::isPressed(pad, JUMP_BTN);
|
||
if (PAD_NOW && !jump_held_gamepad_) { edge = true; }
|
||
jump_held_gamepad_ = PAD_NOW;
|
||
|
||
return edge;
|
||
}
|
||
|
||
auto Player::consumeAttackEdge() -> bool {
|
||
// Mateix patró que `consumeJumpEdge` però per al botó d'atac. El botó
|
||
// físic ve d'`GamepadBindings`, persistit per dispositiu — l'usuari
|
||
// l'ha pogut remapejar des del menú de servei.
|
||
const bool* keys = InputState::getKeyboardState();
|
||
const bool KEY_NOW = keys[controls_.attack];
|
||
bool edge = (KEY_NOW && !attack_held_keyboard_);
|
||
attack_held_keyboard_ = KEY_NOW;
|
||
|
||
SDL_Gamepad* pad = GamepadManager::get().getForPlayer(id_);
|
||
const auto& slot = (id_ == 0) ? Options::controls().gamepad_p1
|
||
: Options::controls().gamepad_p2;
|
||
const int ATK_BTN = GamepadBindings::get().getButton(slot.name, slot.path, GamepadBindings::Action::ATTACK);
|
||
const bool PAD_NOW = (pad != nullptr) && !InputState::isBlocked() &&
|
||
GamepadBindings::isPressed(pad, ATK_BTN);
|
||
if (PAD_NOW && !attack_held_gamepad_) { edge = true; }
|
||
attack_held_gamepad_ = PAD_NOW;
|
||
|
||
return edge;
|
||
}
|
||
|
||
auto Player::currentFacingCardinal() const -> Cardinal {
|
||
switch (facing_) {
|
||
case Facing::NORTH:
|
||
return Cardinal::NORTH;
|
||
case Facing::SOUTH:
|
||
return Cardinal::SOUTH;
|
||
case Facing::EAST:
|
||
return Cardinal::EAST;
|
||
case Facing::WEST:
|
||
return Cardinal::WEST;
|
||
}
|
||
return Cardinal::EAST;
|
||
}
|
||
|
||
void Player::enterAttacking(Cardinal facing) {
|
||
state_ = State::ATTACKING;
|
||
attacking_remaining_ms_ = static_cast<float>(whip_config.swing_ms + whip_config.cooldown_ms);
|
||
|
||
// Garantim que facing/flip i cardinal queden coherents amb la
|
||
// direcció triada (per si l'atac s'ha disparat amb facing residual,
|
||
// p.ex. just després d'un canvi d'input).
|
||
applyCardinal(facing);
|
||
|
||
if (facing == Cardinal::NONE) { return; }
|
||
|
||
// So del swing — disparat ací (no des del WhipAttack) perquè és el
|
||
// jugador qui executa l'acció; si demà el spawn falla per qualsevol
|
||
// motiu, el so del gest segueix sonant.
|
||
if (!whip_config.sound_key.empty() && Audio::get() != nullptr) {
|
||
Audio::get()->playSound(whip_config.sound_key, Audio::Group::GAME);
|
||
}
|
||
|
||
// Mapa de Cardinal → int al payload (vegeu `WhipSpawnPayload`).
|
||
int dir_int = 0;
|
||
switch (facing) {
|
||
case Cardinal::NORTH:
|
||
dir_int = 1;
|
||
break;
|
||
case Cardinal::SOUTH:
|
||
dir_int = 2;
|
||
break;
|
||
case Cardinal::EAST:
|
||
dir_int = 3;
|
||
break;
|
||
case Cardinal::WEST:
|
||
dir_int = 4;
|
||
break;
|
||
case Cardinal::NONE:
|
||
return;
|
||
}
|
||
|
||
// Passem el pixel_rect (16×32 a sam/morcus) al payload; el WhipAttack
|
||
// en deriva tant la draw_rect (sprite del látigo, més gran) com la
|
||
// damage_rect (al nivell dels peus, retallada per bloquejadors).
|
||
if (bus_ != nullptr) {
|
||
const float RANGE_PX = whip_config.range_tiles * static_cast<float>(Map::tile_size);
|
||
bus_->publish("whip_spawn", WhipSpawnPayload{
|
||
.owner = this,
|
||
.direction = dir_int,
|
||
.owner_box = getPosition(),
|
||
.range_px = RANGE_PX,
|
||
});
|
||
}
|
||
}
|
||
|
||
void Player::startJump() {
|
||
// No saltem si l'estat no ho permet. `being_pushed_` no és segur de
|
||
// consultar ací (es pot haver marcat o no, segons l'ordre del frame),
|
||
// així que la regla és: només GROUNDED arrenca salts. El push viu
|
||
// entre `tick` i `applyMovement`, així que si el resolver acaba
|
||
// empentant en mig d'un frame on intentàvem saltar, el flanc es perd
|
||
// — comportament acceptable: l'empenta guanya al toc curt.
|
||
if (state_ != State::GROUNDED) { return; }
|
||
|
||
// Un gir de 180° en curs es compromet immediatament: si l'usuari
|
||
// ha disparat un giro (oest→est, p.ex.) i salta abans que s'esgote
|
||
// el timer, l'animació del salt ha de mostrar la direcció final
|
||
// (jump lateral), no la intermèdia (jump_south). Aplem el pending
|
||
// ara mateix i resetem l'estat del gir. Si no hi havia gir en
|
||
// curs, és no-op.
|
||
if (turning_remaining_ms_ > 0.0F) {
|
||
applyCardinal(pending_cardinal_);
|
||
turning_remaining_ms_ = 0.0F;
|
||
pending_cardinal_ = Cardinal::NONE;
|
||
intermediate_cardinal_ = Cardinal::NONE;
|
||
}
|
||
|
||
state_ = State::AIRBORNE;
|
||
jump_z_ = 0.0F;
|
||
jump_vz_ = jump_config.impulse_px_per_s;
|
||
if (!jump_config.sound_key.empty() && Audio::get() != nullptr) {
|
||
Audio::get()->playSound(jump_config.sound_key, Audio::Group::GAME);
|
||
}
|
||
// Avisem al bus perquè el GameScene puga spawnejar el polvet de
|
||
// despegue al peu del jugador.
|
||
if (bus_ != nullptr) {
|
||
bus_->publish(Event{
|
||
.name = "player_jumped",
|
||
.payload = WorldPos{.x = getPosX() + (static_cast<float>(sprite_w) * 0.5F), .y = getGroundFeetY()},
|
||
.player_id = id_,
|
||
});
|
||
}
|
||
// Prou: la velocitat horitzontal del salt és la `intended_v*` actual
|
||
// (input del frame). El facing també queda com està. Ja no es
|
||
// tocaran fins a aterrar.
|
||
}
|
||
|
||
auto Player::deltasFor(Cardinal c) -> std::pair<float, float> {
|
||
switch (c) {
|
||
case Cardinal::EAST:
|
||
return {1.0F, 0.0F};
|
||
case Cardinal::WEST:
|
||
return {-1.0F, 0.0F};
|
||
case Cardinal::NORTH:
|
||
return {0.0F, -1.0F};
|
||
case Cardinal::SOUTH:
|
||
return {0.0F, 1.0F};
|
||
case Cardinal::NONE:
|
||
break;
|
||
}
|
||
return {0.0F, 0.0F};
|
||
}
|
||
|
||
auto Player::reachInDirection(float dx, float dy, int max_px) const -> int {
|
||
const auto BASE = getBaseBox();
|
||
for (int i = 1; i <= max_px; ++i) {
|
||
SDL_FRect probe = BASE;
|
||
probe.x += static_cast<float>(i) * dx;
|
||
probe.y += static_cast<float>(i) * dy;
|
||
if (map_.collides(probe) || !map_.inside_playfield(probe)) { return i - 1; }
|
||
}
|
||
return max_px;
|
||
}
|
||
|
||
auto Player::chooseHurtDirection(Cardinal preferred) const -> Cardinal {
|
||
constexpr int MAX_PX = 32; // 2 tiles, prou per a la cerca; la inèrcia limita el recorregut real.
|
||
|
||
// Recorregut natural del HURT (distància que recórreria sota
|
||
// decaiment lineal pur). Si la direcció preferida cabe sense topar
|
||
// abans d'aquesta distància, l'usem; d'altra manera, busquem la
|
||
// millor.
|
||
const auto REQUIRED = static_cast<int>(
|
||
0.5F * hurt_config.initial_speed_px_per_s * (static_cast<float>(hurt_config.brake_time_ms) / 1000.0F));
|
||
|
||
if (preferred != Cardinal::NONE) {
|
||
const auto [DX, DY] = deltasFor(preferred);
|
||
if (reachInDirection(DX, DY, MAX_PX) >= REQUIRED) { return preferred; }
|
||
}
|
||
|
||
constexpr std::array<Cardinal, 4> ALL{Cardinal::EAST, Cardinal::WEST, Cardinal::NORTH, Cardinal::SOUTH};
|
||
int best_reach = -1;
|
||
Cardinal best_dir = Cardinal::EAST;
|
||
for (Cardinal c : ALL) {
|
||
const auto [DX, DY] = deltasFor(c);
|
||
const int R = reachInDirection(DX, DY, MAX_PX);
|
||
if (R > best_reach) {
|
||
best_reach = R;
|
||
best_dir = c;
|
||
}
|
||
}
|
||
return best_dir;
|
||
}
|
||
|
||
void Player::enterHurt(Cardinal preferred) {
|
||
// 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 (hurt_config.initial_speed_px_per_s <= 0.0F || hurt_config.brake_time_ms <= 0) { return; }
|
||
|
||
const Cardinal CHOSEN = chooseHurtDirection(preferred);
|
||
|
||
// Activem l'estat i fixem la velocitat segons la direcció triada.
|
||
state_ = State::HURT;
|
||
if (!hurt_config.sound_key.empty() && Audio::get() != nullptr) {
|
||
Audio::get()->playSound(hurt_config.sound_key, Audio::Group::GAME);
|
||
}
|
||
applyCardinal(CHOSEN);
|
||
last_input_cardinal_ = CHOSEN;
|
||
turning_remaining_ms_ = 0.0F;
|
||
pending_cardinal_ = Cardinal::NONE;
|
||
intermediate_cardinal_ = Cardinal::NONE;
|
||
being_pushed_ = false;
|
||
const float V = hurt_config.initial_speed_px_per_s;
|
||
const auto [DX, DY] = deltasFor(CHOSEN);
|
||
intended_vx_ = DX * V;
|
||
intended_vy_ = DY * V;
|
||
input_vx_ = 0.0F;
|
||
input_vy_ = 0.0F;
|
||
motion_time_ms_ = 0.0F;
|
||
}
|
||
|
||
auto Player::integrateJump(float delta_time) -> bool {
|
||
if (state_ != State::AIRBORNE) { return false; }
|
||
jump_vz_ -= jump_config.gravity_px_per_s2 * delta_time;
|
||
jump_z_ += jump_vz_ * delta_time;
|
||
// Caiguda per agujero: el sòl ja no és present sota el jugador, així
|
||
// que NO clampem `jump_z_` a 0. Deixem baixar a negatius (visualment
|
||
// el sprite "s'enfonsa" sota el sòl) i, en superar `HOLE_FALL_DEPTH`,
|
||
// disparem la mort. La cinemàtica de respawn (`enterDying →
|
||
// spawnFromAir`) reseteja el flag i la posició al spawn point.
|
||
if (falling_through_hole_) {
|
||
if (jump_z_ <= -HOLE_FALL_DEPTH) {
|
||
enterDying(DeathCause::HOLE);
|
||
}
|
||
return false;
|
||
}
|
||
if (jump_z_ <= 0.0F) {
|
||
// Aterratge sobre decal-trigger (Hole, futurs pinchos plans...):
|
||
// saltem el LANDING_STUN i transicionem directament a la caiguda
|
||
// pel forat. Sense aquesta detecció, l'aterratge entraria a
|
||
// LANDING_STUN (~200 ms flotant sobre el forat) i només quan
|
||
// tornés a GROUNDED `Hole::onPlayerContact` ho dispararia. La
|
||
// hit-box (8×8 centrada al sprite) és la mateixa que `Hole`
|
||
// usa al seu re-check; coherent. Si el manager no s'ha
|
||
// injectat (tests stand-alone), el camí d'aterratge segueix sent
|
||
// el habitual.
|
||
if (entity_manager_ != nullptr) {
|
||
const Entity* trigger = entity_manager_->findFloorTriggerAt(getHitBox());
|
||
if (trigger != nullptr) {
|
||
const auto BOX = trigger->getCollisionRect();
|
||
const float CENTER_X = BOX.x + (BOX.w * 0.5F);
|
||
const float CENTER_Y = BOX.y + (BOX.h * 0.5F);
|
||
const float FLOOR_Y = BOX.y + BOX.h;
|
||
enterHoleFall(CENTER_X, CENTER_Y, FLOOR_Y);
|
||
// No retornem `true`: no és un aterratge "normal" i
|
||
// `applyMovement` no ha de cridar `resolveLandingOverlap`.
|
||
return false;
|
||
}
|
||
}
|
||
jump_z_ = 0.0F;
|
||
jump_vz_ = 0.0F;
|
||
state_ = State::LANDING_STUN;
|
||
landing_stun_remaining_ms_ = static_cast<float>(jump_config.landing_stun_ms);
|
||
if (!jump_config.land_sound_key.empty() && Audio::get() != nullptr) {
|
||
// Consumeix el preset armat per GameScene al spawn aeri
|
||
// d'INTRO (single-shot): el primer LAND el sona reverberat,
|
||
// els salts següents cauen al camí sec.
|
||
if (next_land_reverb_preset_.empty()) {
|
||
Audio::get()->playSound(jump_config.land_sound_key, Audio::Group::GAME);
|
||
} else {
|
||
Audio::get()->playSoundWithReverb(jump_config.land_sound_key, next_land_reverb_preset_, Audio::Group::GAME);
|
||
next_land_reverb_preset_.clear();
|
||
}
|
||
}
|
||
if (bus_ != nullptr) {
|
||
bus_->publish(Event{
|
||
.name = "player_landed",
|
||
.payload = WorldPos{.x = getPosX() + (static_cast<float>(sprite_w) * 0.5F), .y = getGroundFeetY()},
|
||
.player_id = id_,
|
||
});
|
||
}
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
void Player::resolveLandingOverlap() {
|
||
// Quan el saltador (this) aterra al damunt d'un altre jugador, el
|
||
// saltador NO es desplaça — es queda al lloc en LANDING_STUN. En
|
||
// canvi, qualsevol jugador amb el qual solape entra en HURT i
|
||
// surt projectat. Així el solapament inicial es resol per moviment
|
||
// del solapat, no per desplaçament forçat al lloc.
|
||
//
|
||
// 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 (!hurt_config.sound_key.empty() && Audio::get() != nullptr) {
|
||
Audio::get()->playSound(hurt_config.sound_key, Audio::Group::GAME);
|
||
}
|
||
}
|
||
|
||
void Player::applyDyingMovement(float delta_time) {
|
||
// Avancem l'animació; el sprite no es mou (la posició queda
|
||
// congelada al punt de la mort). `animationIsCompleted()` flanqueja
|
||
// un cop la roda arriba al darrer frame (loop:false). Llavors
|
||
// descomptem `respawn_delay_ms` mantenint el sprite al darrer frame
|
||
// i, en arribar a 0, disparem el respawn.
|
||
animate(delta_time);
|
||
if (!death_animation_complete_ && animationIsCompleted()) {
|
||
death_animation_complete_ = true;
|
||
respawn_remaining_ms_ = static_cast<float>(death_config.respawn_delay_ms);
|
||
}
|
||
if (death_animation_complete_) {
|
||
respawn_remaining_ms_ -= delta_time * 1000.0F;
|
||
if (respawn_remaining_ms_ <= 0.0F) {
|
||
respawnFromDying();
|
||
}
|
||
}
|
||
}
|
||
|
||
void Player::resetForJoin() {
|
||
lives_remaining_ = death_config.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 &&
|
||
immunity_config.blink_period_ms > 0 &&
|
||
!immunity_config.palette_key.empty() &&
|
||
surface_ != nullptr &&
|
||
(static_cast<int>(immunity_remaining_ms_) / immunity_config.blink_period_ms) % 2 == 0;
|
||
if (BLINK_ON) {
|
||
surface_->setPalette(Resource::Cache::get().getPalette(immunity_config.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>(sprite_w) * 0.5F) + static_cast<float>(light_offset_x),
|
||
.y = getPosY() - jump_z_ + (static_cast<float>(sprite_h) * 0.5F) + static_cast<float>(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 `jump_config` té un divisor zero (cas degenerat) caem
|
||
// a 0 per no propagar nan al renderer.
|
||
const auto PEAK = (jump_config.gravity_px_per_s2 > 0.0F)
|
||
? (jump_config.impulse_px_per_s * jump_config.impulse_px_per_s) / (2.0F * jump_config.gravity_px_per_s2)
|
||
: 0.0F;
|
||
const float 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>(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>(whip_config.cooldown_ms));
|
||
return std::string(statePrefix(state_, moving, IN_ATTACK_SWING)) + facingSuffix(facing_);
|
||
}
|
||
|
||
auto Player::getCurrentTile() const -> std::pair<int, int> {
|
||
// El grid del tilemap és de `tile_size` píxels i està 16-alineat
|
||
// respecte a l'origen del tilemap (pot ser negatiu per overscan). El
|
||
// centre de la caixa de col·lisió ens dóna el tile on "està" el Player.
|
||
const auto BOX = baseBox();
|
||
const float CX = BOX.x + (BOX.w * 0.5F);
|
||
const float CY = BOX.y + (BOX.h * 0.5F);
|
||
const int COL = static_cast<int>(std::floor(
|
||
(CX - static_cast<float>(map_.tilemap_origin_x)) / static_cast<float>(map_.tile_size)));
|
||
const int ROW = static_cast<int>(std::floor(
|
||
(CY - static_cast<float>(map_.tilemap_origin_y)) / static_cast<float>(map_.tile_size)));
|
||
return {COL, ROW};
|
||
}
|
||
|
||
void Player::clampToPlayfield() {
|
||
// Límits de la zona jugable: la caixa de col·lisió no pot eixir del
|
||
// playfield (muros). El cap del sprite sí pot sobresortir per damunt.
|
||
const auto MIN_X = static_cast<float>(map_.playfield_x - collision_x_offset);
|
||
const auto MAX_X = static_cast<float>(
|
||
map_.playfield_x + map_.playfield_w - collision_x_offset - collision_size);
|
||
auto min_y = static_cast<float>(map_.playfield_y - collision_y_offset);
|
||
const auto MAX_Y = static_cast<float>(
|
||
map_.playfield_y + map_.playfield_h - collision_y_offset - collision_size);
|
||
|
||
// Passadís de sortida: si la base box queda enterament dins del rang
|
||
// X del passadís de la porta, deixem que pugi al strip del mur trasser
|
||
// (fins a `door_corridor.y`). Fora del rang, MIN_Y es queda al
|
||
// playfield. La regla "enterament dins" exigeix alinear-se al passadís
|
||
// abans de pujar — la base box (12 px) hi cap amb 4 px de marge.
|
||
if (map_.door_corridor_w > 0) {
|
||
const float BASE_X = getPosX() + static_cast<float>(collision_x_offset);
|
||
const float BASE_RIGHT = BASE_X + static_cast<float>(collision_size);
|
||
const auto CORRIDOR_LEFT = static_cast<float>(map_.door_corridor_x);
|
||
const auto CORRIDOR_RIGHT = static_cast<float>(map_.door_corridor_x + map_.door_corridor_w);
|
||
if (BASE_X >= CORRIDOR_LEFT && BASE_RIGHT <= CORRIDOR_RIGHT) {
|
||
min_y = static_cast<float>(map_.door_corridor_y - collision_y_offset);
|
||
}
|
||
}
|
||
|
||
setPosX(std::clamp(getPosX(), MIN_X, MAX_X));
|
||
setPosY(std::clamp(getPosY(), min_y, MAX_Y));
|
||
}
|
||
|
||
void Player::respawn(float x, float y) {
|
||
state_ = State::GROUNDED;
|
||
intended_vx_ = 0.0F;
|
||
intended_vy_ = 0.0F;
|
||
input_vx_ = 0.0F;
|
||
input_vy_ = 0.0F;
|
||
being_pushed_ = false;
|
||
jump_z_ = 0.0F;
|
||
jump_vz_ = 0.0F;
|
||
falling_through_hole_ = false;
|
||
hole_clip_bottom_y_ = 0.0F;
|
||
landing_stun_remaining_ms_ = 0.0F;
|
||
attacking_remaining_ms_ = 0.0F;
|
||
turning_remaining_ms_ = 0.0F;
|
||
pending_cardinal_ = Cardinal::NONE;
|
||
intermediate_cardinal_ = Cardinal::NONE;
|
||
last_input_cardinal_ = Cardinal::NONE;
|
||
// Flags del cicle DYING (que `respawnFromDying` també neteja, però
|
||
// si algú crida `respawn` directament des d'un altre estat, han de
|
||
// quedar coherents per al pròxim cicle de mort).
|
||
death_animation_complete_ = false;
|
||
respawn_remaining_ms_ = 0.0F;
|
||
// Reseteig del facing al d'spawn original (mirant cap a la càmera):
|
||
// sense això, un EXITED que entrava per la porta amb facing NORTH
|
||
// re-aparix d'esquena al pròxim cicle. Mateixa lògica que el ctor.
|
||
facing_ = Facing::SOUTH;
|
||
setCurrentAnimation("stand_south");
|
||
setPosX(x);
|
||
setPosY(y);
|
||
// 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(immunity_config.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 + hit_size/2)` per a cada eix:
|
||
// així el centre exacte de la caja de contacte queda alineat amb
|
||
// (center_x, center_y), independentment de la geometria interna del
|
||
// sprite. `setPosX/Y` és l'API canònica per teleportar el jugador
|
||
// (la usen `respawn` i `spawnFromAir`).
|
||
const float HIT_HALF = static_cast<float>(hit_size) * 0.5F;
|
||
setPosX(center_x - static_cast<float>(hit_x_offset) - HIT_HALF);
|
||
setPosY(center_y - static_cast<float>(hit_y_offset) - HIT_HALF);
|
||
|
||
// Frenem qualsevol inèrcia prèvia: la caiguda ha de ser estàtica al
|
||
// centre del forat.
|
||
intended_vx_ = 0.0F;
|
||
intended_vy_ = 0.0F;
|
||
input_vx_ = 0.0F;
|
||
input_vy_ = 0.0F;
|
||
motion_time_ms_ = 0.0F;
|
||
being_pushed_ = false;
|
||
setVelX(0.0F);
|
||
setVelY(0.0F);
|
||
|
||
jump_z_ = 0.0F;
|
||
jump_vz_ = 0.0F;
|
||
state_ = State::AIRBORNE;
|
||
falling_through_hole_ = true;
|
||
// Línia visual on el clip rect ha de retallar el sprite. Restem
|
||
// `SPRITE_FLOOR_OFFSET` perquè la base del sprite té píxels
|
||
// transparents — el retall ha de coincidir amb la silueta visible,
|
||
// no amb la vora del rect del sprite.
|
||
hole_clip_bottom_y_ = floor_y - SPRITE_FLOOR_OFFSET;
|
||
|
||
// Animació de caiguda: respectem el `facing_` que ja portava el jugador
|
||
// (no té sentit reorientar-lo cap al sud només perquè caigui en un
|
||
// forat). Com `state_` ja és AIRBORNE, `animationName(false)` resol el
|
||
// prefix `jump_` i hi afegeix el sufix cardinal correcte —
|
||
// `jump_north`, `jump_south`, `jump_east` o `jump_west` segons el
|
||
// facing actual. Si demà cal una animació de "cara de pànic", només
|
||
// cal canviar aquí pel `setCurrentAnimation` corresponent.
|
||
setCurrentAnimation(animationName(false));
|
||
}
|
||
|
||
void Player::spawnFromAir(float x, float y) {
|
||
// Net total: equival a un `respawn` seguit d'una entrada en l'aire.
|
||
// Així el flag de gir, l'animació, els flancs del salt i les
|
||
// velocitats residuals queden a un estat conegut abans de promocionar
|
||
// a AIRBORNE. Si hi havia un cardinal d'input penjat de la sessió
|
||
// anterior, no contamina la pròxima caiguda.
|
||
respawn(x, y);
|
||
|
||
// Altura derivada de la geometria: prou per arrencar amb el sprite
|
||
// sencer per damunt del bord superior del canvas (recordem que
|
||
// visualment es pinta a `pos_.y - jump_z_`). Afegim un xicotet marge
|
||
// perquè el primer frame ja siga clarament off-screen, no just al ras.
|
||
constexpr float OFFSCREEN_MARGIN_PX = 8.0F;
|
||
const float SPAWN_HEIGHT = y + static_cast<float>(sprite_h) + OFFSCREEN_MARGIN_PX;
|
||
|
||
state_ = State::AIRBORNE;
|
||
jump_z_ = SPAWN_HEIGHT;
|
||
// Caiguda recta: sense impuls inicial, deixem que la gravetat (la
|
||
// mateixa que la del salt normal) faça la feina. Velocitat horitzontal
|
||
// a zero perquè no es desviï del passadís d'spawn.
|
||
jump_vz_ = 0.0F;
|
||
intended_vx_ = 0.0F;
|
||
intended_vy_ = 0.0F;
|
||
|
||
// Mirant cap avall durant la caiguda — `animationName(AIRBORNE)` amb
|
||
// facing SOUTH ja resol a `jump_south`. `respawn` ja ha posat facing a
|
||
// SOUTH; ho deixem explícit ací per claredat de la cinemàtica.
|
||
facing_ = Facing::SOUTH;
|
||
setCurrentAnimation("jump_south");
|
||
}
|
||
|
||
auto Player::getBaseBox() const -> SDL_FRect {
|
||
return {
|
||
.x = getPosX() + static_cast<float>(collision_x_offset),
|
||
.y = getPosY() + static_cast<float>(collision_y_offset),
|
||
.w = static_cast<float>(collision_size),
|
||
.h = static_cast<float>(collision_size),
|
||
};
|
||
}
|
||
|
||
auto Player::getHitBox() const -> SDL_FRect {
|
||
return {
|
||
.x = getPosX() + static_cast<float>(hit_x_offset),
|
||
.y = getPosY() + static_cast<float>(hit_y_offset),
|
||
.w = static_cast<float>(hit_size),
|
||
.h = static_cast<float>(hit_size),
|
||
};
|
||
}
|
||
|
||
auto Player::hitBoxFromBase(const SDL_FRect& base) -> SDL_FRect {
|
||
// Desplaçament intern respecte al cantó de la caixa base: mateixos
|
||
// offsets que `getHitBox()` però partint d'una caixa arbitrària.
|
||
const int INSET = (collision_size - hit_size) / 2;
|
||
return {
|
||
.x = base.x + static_cast<float>(INSET),
|
||
.y = base.y + static_cast<float>(INSET),
|
||
.w = static_cast<float>(hit_size),
|
||
.h = static_cast<float>(hit_size),
|
||
};
|
||
}
|
||
|
||
void Player::snapTowardsLane(
|
||
float delta_time,
|
||
float target_lane,
|
||
float (Player::*get_pos)() const,
|
||
void (Player::*set_pos)(float)) {
|
||
const auto THRESHOLD = static_cast<float>(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 = 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 - collision_size) / 2.0F);
|
||
const auto THRESHOLD = static_cast<float>(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 - 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 - 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 - collision_size) / 2.0F);
|
||
const float LANE_DOOR = CORRIDOR_CENTER - static_cast<float>(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);
|
||
}
|