feat(player): nou estat HURT — caure damunt d'un altre el llança projectat

This commit is contained in:
JailDesigner committed 2026-04-25 14:56:15 +02:00
1 parent 188f035333
commit e8407ce35e
5 files changed
+310 -119

No files matched your search

+203 -105
View File
@@ -3,6 +3,7 @@
#include <SDL3/SDL.h>
#include <algorithm>
#include <array>
#include <cmath>
#include <stdexcept>
#include <utility>
@@ -66,6 +67,7 @@ namespace {
Player::TurnConfig Player::turn_config;
Player::InertiaConfig Player::inertia_config;
Player::JumpConfig Player::jump_config;
Player::HurtConfig Player::hurt_config;
void Player::loadFrom(const std::string& yaml_path) {
auto bytes = Resource::Helper::loadFile(yaml_path);
@@ -108,6 +110,10 @@ void Player::loadFrom(const std::string& yaml_path) {
// botó de salt no fa res. Extret a un mètode estàtic per acotar la
// complexitat cognitiva de `loadFrom` sota el llindar de clang-tidy.
readJumpBlock(yaml);
// Bloc opcional `hurt:` — estat post-impacte. Si manca o el speed
// és 0, l'estat HURT mai s'activa (cas degenerat).
readHurtBlock(yaml);
}
void Player::readCollisionBlock(const fkyaml::node& yaml, const std::string& yaml_path) {
@@ -161,6 +167,14 @@ void Player::readJumpBlock(const fkyaml::node& yaml) {
}
}
void Player::readHurtBlock(const fkyaml::node& yaml) {
hurt_config = HurtConfig{};
if (!yaml.contains("hurt")) { return; }
const auto& h = yaml["hurt"];
if (h.contains("speed")) { hurt_config.initial_speed_px_per_s = h["speed"].get_value<float>(); }
if (h.contains("brake_time_ms")) { hurt_config.brake_time_ms = h["brake_time_ms"].get_value<int>(); }
}
void Player::applyCardinal(Cardinal c) {
switch (c) {
case Cardinal::NORTH:
@@ -323,7 +337,9 @@ auto Player::collidesWithWorld(const SDL_FRect& box) const -> bool {
// acostar-se fins al contacte real.
//
// Regla de col·lisió jugador↔jugador (segons el feel triat):
// - Tots dos a terra → col·lisió normal.
// - 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.
@@ -333,9 +349,11 @@ auto Player::collidesWithWorld(const SDL_FRect& box) const -> bool {
// mateixa franja d'alçada.
const SDL_FRect PROPOSED_HIT = hitBoxFromBase(box);
const bool MY_AIR = (state_ == State::AIRBORNE);
const bool MY_HURT = (state_ == State::HURT);
const float MY_Z = jump_z_;
return std::ranges::any_of(*all_players_, [this, &PROPOSED_HIT, MY_AIR, MY_Z](const Player* p) {
return std::ranges::any_of(*all_players_, [this, &PROPOSED_HIT, MY_AIR, MY_HURT, MY_Z](const Player* p) {
if (p == nullptr || p == this) { return false; }
if (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,
@@ -422,6 +440,36 @@ void Player::tick(float delta_time) {
return;
}
// En HURT el jugador està projectat sense control: descomptem
// velocitat per inèrcia (decaiment lineal fins a 0). Quan arriba a
// zero, tornem a GROUNDED i el frame següent ja torna a llegir input.
// El moviment real l'aplica `applyMovement` (igual que els altres
// estats), que també gestiona el xoc contra murs.
if (state_ == State::HURT) {
(void)consumeJumpEdge();
const float DECEL = (hurt_config.brake_time_ms > 0)
? hurt_config.initial_speed_px_per_s / (static_cast<float>(hurt_config.brake_time_ms) / 1000.0F)
: 0.0F;
const float STEP = DECEL * delta_time;
auto decay = [STEP](float& v) {
if (std::fabs(v) <= STEP) {
v = 0.0F;
} else {
v -= std::copysign(STEP, v);
}
};
decay(intended_vx_);
decay(intended_vy_);
if (intended_vx_ == 0.0F && intended_vy_ == 0.0F) {
state_ = State::GROUNDED;
}
input_vx_ = 0.0F;
input_vy_ = 0.0F;
setVelX(intended_vx_);
setVelY(intended_vy_);
return;
}
// Via `InputState` en lloc de `SDL_GetKeyboardState` directe: quan el
// service menu (o qualsevol UI modal) està obert, el wrapper retorna
// un buffer tot-a-zero perquè el jugador no es mogui mentre l'usuari
@@ -522,6 +570,79 @@ void Player::startJump() {
// tocaran fins a aterrar.
}
void Player::enterHurt() {
// 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; }
// Triem la direcció amb més recorregut lliure: provem els 4
// cardinals i ens quedem el de major distància contra muros/sarcs.
// El test no comprova jugadors (durant HURT el jugador és
// intangible al PvP), només geometria del mapa.
constexpr int MAX_PX = 32; // 2 tiles, prou per a la cerca; la inèrcia limita el recorregut real.
struct Candidate {
Cardinal dir;
float dx;
float dy;
};
constexpr std::array<Candidate, 4> ALL{{
{.dir = Cardinal::EAST, .dx = 1.0F, .dy = 0.0F},
{.dir = Cardinal::WEST, .dx = -1.0F, .dy = 0.0F},
{.dir = Cardinal::NORTH, .dx = 0.0F, .dy = -1.0F},
{.dir = Cardinal::SOUTH, .dx = 0.0F, .dy = 1.0F},
}};
const auto BASE = getBaseBox();
int best_reach = -1;
Cardinal best_dir = Cardinal::EAST;
for (const auto& c : ALL) {
int reach = MAX_PX;
for (int i = 1; i <= MAX_PX; ++i) {
SDL_FRect probe = BASE;
probe.x += static_cast<float>(i) * c.dx;
probe.y += static_cast<float>(i) * c.dy;
if (map_.collides(probe) || !map_.inside_playfield(probe)) {
reach = i - 1;
break;
}
}
if (reach > best_reach) {
best_reach = reach;
best_dir = c.dir;
}
}
// Activem l'estat i fixem la velocitat segons la direcció triada.
state_ = State::HURT;
applyCardinal(best_dir);
last_input_cardinal_ = best_dir;
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;
intended_vx_ = 0.0F;
intended_vy_ = 0.0F;
switch (best_dir) {
case Cardinal::EAST:
intended_vx_ = V;
break;
case Cardinal::WEST:
intended_vx_ = -V;
break;
case Cardinal::NORTH:
intended_vy_ = -V;
break;
case Cardinal::SOUTH:
intended_vy_ = V;
break;
case Cardinal::NONE:
break;
}
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;
@@ -540,73 +661,22 @@ auto Player::integrateJump(float delta_time) -> bool {
}
void Player::resolveLandingOverlap() {
// Quan el saltador (this) aterra al damunt d'un altre jugador, el
// saltador NO es desplaça — es queda al lloc en LANDING_STUN. En
// canvi, qualsevol jugador amb el qual solape entra en HURT i
// surt projectat. Així el solapament inicial es resol per moviment
// del solapat, no per desplaçament forçat al lloc.
if (all_players_ == nullptr) { return; }
for (Player* other : *all_players_) {
if (other == nullptr || other == this) { continue; }
// Usem la BASE-box (16×16, el cos sencer) per al test, no la
// hit-box (8×8 de contacte). Volem separar els cossos
// visualment: si fem servir només la hit-box, dos jugadors
// poden quedar amb les base-boxes solapades a mig sprite (un
// dins de l'altre) tot i tenir les hit-boxes ja apartades. La
// base-box dóna la separació real del cos.
// Test amb la BASE-box (cos sencer): si els cossos solapen,
// l'altre es projecta. Usem la base-box i no la hit-box perquè
// ens importa la separació visual completa, no només els
// píxels de contacte.
const SDL_FRect MY_BOX = getBaseBox();
const SDL_FRect OT_BOX = other->getBaseBox();
SDL_FRect inter;
if (!SDL_GetRectIntersectionFloat(&MY_BOX, &OT_BOX, &inter)) { continue; }
// Eix de separació: el que té els centres més separats. És
// l'axis-aligned MTV "natural" — equivalent a empentar l'altre
// cap a fora seguint la direcció centre→centre, projectada al
// costat dominant. Coincideix amb la intuïció: si el saltador
// li cau una mica per sobre i a la dreta, l'altre va cap a la
// dreta-baix; si li cau directament al damunt (dx≈0), l'altre
// s'aparta verticalment.
const float MCX = MY_BOX.x + (MY_BOX.w * 0.5F);
const float MCY = MY_BOX.y + (MY_BOX.h * 0.5F);
const float OCX = OT_BOX.x + (OT_BOX.w * 0.5F);
const float OCY = OT_BOX.y + (OT_BOX.h * 0.5F);
const float DX = OCX - MCX;
const float DY = OCY - MCY;
float push_x = 0.0F;
float push_y = 0.0F;
if (std::fabs(DX) >= std::fabs(DY)) {
push_x = (DX >= 0.0F ? 1.0F : -1.0F) * inter.w;
} else {
push_y = (DY >= 0.0F ? 1.0F : -1.0F) * inter.h;
}
// 1r intent: empentar l'altre. Si la nova posició no col·lisiona
// amb res (mur, sarc, tercer jugador), commit i marquem-lo com
// a empentat per coherència de l'animació de l'altre frame.
const float OSX = other->getPosX();
const float OSY = other->getPosY();
other->setPosX(OSX + push_x);
other->setPosY(OSY + push_y);
if (!other->collidesWithWorld(other->getBaseBox())) {
other->setBeingPushed(true);
continue;
}
// L'altre xocaria amb un mur: revertim i provem d'apartar el
// saltador cap al sentit oposat. Així respectem la regla "mai
// ficar ningú dins d'un mur": si no hi ha camí per a l'altre,
// qui es desplaça és el qui ha triat saltar.
other->setPosX(OSX);
other->setPosY(OSY);
const float MSX = getPosX();
const float MSY = getPosY();
setPosX(MSX - push_x);
setPosY(MSY - push_y);
if (!collidesWithWorld(getBaseBox())) {
continue;
}
// Cap dels dos pot apartar-se sense xocar amb un mur. Cas
// degenerat (jugador encaixonat entre obstacles); el deixem com
// estava i acceptem el solapament residual fins al pròxim frame
// amb input.
setPosX(MSX);
setPosY(MSY);
if (!SDL_HasRectIntersectionFloat(&MY_BOX, &OT_BOX)) { continue; }
other->enterHurt();
}
}
@@ -621,25 +691,13 @@ void Player::applyMovement(float delta_time) {
resolveLandingOverlap();
}
// En LANDING_STUN no movem el jugador horitzontalment (la velocitat
// ja s'ha posat a 0 al `tick`). Encara així animem (per estar al
// frame correcte de la posada estàtica) i emetem el tile actual,
// perquè la tile d'aterratge sí que es pinte.
if (state_ == State::HURT) {
applyHurtMovement(delta_time);
return;
}
if (state_ == State::LANDING_STUN) {
animate(delta_time);
setCurrentAnimation(animationName(false));
if (bus_ != nullptr) {
const auto [COL, ROW] = getCurrentTile();
if (COL != last_tile_col_ || ROW != last_tile_row_) {
last_tile_col_ = COL;
last_tile_row_ = ROW;
bus_->publish(Event{
.name = "on_tile_entered",
.payload = PaintedTile{.col = COL, .row = ROW, .tile_index = paint_tile_index_},
.player_id = id_,
});
}
}
applyLandingStunMovement(delta_time);
return;
}
@@ -689,30 +747,56 @@ void Player::applyMovement(float delta_time) {
const bool ANIM_AS_MOVING = MOVED && (turning_remaining_ms_ <= 0.0F) && !being_pushed_;
setCurrentAnimation(animationName(ANIM_AS_MOVING));
// Detecció de canvi de tile. Es calcula *després* del clamp i de les
// col·lisions perquè la posició aquí ja és la definitiva del frame;
// així el PaintLayer no rep falsos positius mentre el Player rellisca
// contra una paret. Si el tile ha canviat, publiquem `on_tile_entered`
// al bus amb l'índex de pintat d'aquest jugador; el PaintLayer filtra
// pintabilitat i re-publica `tile_painted` als sarcòfags.
//
// En AIRBORNE NO publiquem cap pintat — el jugador està en l'aire i
// no està trepitjant cap tile. NI tampoc actualitzem `last_tile_*`,
// perquè volem que el tile d'aterratge (que es comprova al
// LANDING_STUN) compari contra l'últim tile real (el del despegue) i
// s'emeti com a entrada nova si han canviat.
if (bus_ != nullptr && state_ == State::GROUNDED) {
const auto [COL, ROW] = getCurrentTile();
if (COL != last_tile_col_ || ROW != last_tile_row_) {
last_tile_col_ = COL;
last_tile_row_ = ROW;
bus_->publish(Event{
.name = "on_tile_entered",
.payload = PaintedTile{.col = COL, .row = ROW, .tile_index = paint_tile_index_},
.player_id = id_,
});
}
// Detecció de canvi de tile. Només publiquem en GROUNDED — en
// AIRBORNE el jugador no trepitja, i en HURT/LANDING_STUN les
// pròpies branques del switch ja crida la helper si toca.
if (state_ == State::GROUNDED) { publishTileEnteredIfChanged(); }
}
void Player::applyHurtMovement(float delta_time) {
// En HURT el jugador es desplaça segons `intended_v*` (escrit a
// `enterHurt` i decremental a `tick`). Si `tryMove` no avança
// perquè topa amb un mur, el parem en sec i tornem a GROUNDED —
// honra la regla "salvo col·lisió, llavors para".
animate(delta_time);
const float FRAME_START_X = getPosX();
const float FRAME_START_Y = getPosY();
tryMove(intended_vx_ * delta_time, intended_vy_ * delta_time, [this](const SDL_FRect& box) {
return collidesWithWorld(box);
});
clampToPlayfield();
const bool MOVED = (getPosX() != FRAME_START_X) || (getPosY() != FRAME_START_Y);
const bool WANTED_TO_MOVE = (intended_vx_ != 0.0F) || (intended_vy_ != 0.0F);
if (!MOVED && WANTED_TO_MOVE) {
// Bloquejat per un mur → parem en sec i recuperem el control.
intended_vx_ = 0.0F;
intended_vy_ = 0.0F;
state_ = State::GROUNDED;
}
setCurrentAnimation(animationName(true));
publishTileEnteredIfChanged();
}
void Player::applyLandingStunMovement(float delta_time) {
// En LANDING_STUN no movem (la velocitat ja és 0). Animem per
// mantenir el frame del stun i emetem la tile d'aterratge perquè
// es pinte.
animate(delta_time);
setCurrentAnimation(animationName(false));
publishTileEnteredIfChanged();
}
void Player::publishTileEnteredIfChanged() {
if (bus_ == nullptr) { return; }
const auto [COL, ROW] = getCurrentTile();
if (COL == last_tile_col_ && ROW == last_tile_row_) { return; }
last_tile_col_ = COL;
last_tile_row_ = ROW;
bus_->publish(Event{
.name = "on_tile_entered",
.payload = PaintedTile{.col = COL, .row = ROW, .tile_index = paint_tile_index_},
.player_id = id_,
});
}
void Player::render() {
@@ -799,6 +883,20 @@ auto Player::animationName(bool moving) const -> const char* {
}
return "stun";
}
// En HURT, `hurt_*` (per direcció): el jugador llisca sense
// control en la direcció triada per `enterHurt`. Mateixa estructura
// que walk per facilitat (ara mateix els frames són clons).
if (state_ == State::HURT) {
switch (facing_) {
case Facing::UP:
return "hurt_up";
case Facing::DOWN:
return "hurt_down";
case Facing::SIDE:
break;
}
return "hurt";
}
switch (facing_) {
case Facing::UP:
return moving ? "walk_up" : "stand_up";
+54 -14
View File
@@ -28,15 +28,22 @@ class Player : public AnimatedSprite {
// Estat d'alt nivell del jugador. Ortogonal a `Facing`/`Cardinal`
// (direcció) i a `being_pushed_` (flag d'animació). Determina què pot
// fer el jugador aquest frame: GROUNDED accepta tot l'input habitual
// i el botó de salt; AIRBORNE ignora canvis de direcció (la velocitat
// i el facing queden fixats al moment del despegue, sols la física
// del salt mou jump_z_/jump_vz_); LANDING_STUN bloca tot l'input fins
// que el timer s'esgota.
// fer el jugador aquest frame:
// GROUNDED → input normal, pot saltar.
// AIRBORNE → física del salt; ignora input direccional, sense
// col·lisió amb caminants però sí amb altres
// saltadors a la mateixa franja d'alçada (rebot).
// LANDING_STUN → 200ms post-aterratge, sense input ni moviment.
// HURT → projectat sense control (cau d'algú al damunt o,
// en el futur, fuetada): velocitat inicial alta
// que decau per inèrcia fins a parar (~2 tiles).
// No col·lisiona amb cap altre jugador. Quan
// s'atura, torna a GROUNDED.
enum class State : std::uint8_t {
GROUNDED,
AIRBORNE,
LANDING_STUN,
HURT,
};
// Geometria carregada des del YAML del player (p.ex. `data/player/sam.yaml`).
@@ -169,6 +176,12 @@ class Player : public AnimatedSprite {
// GameScene (per saber si renderitzar la sombra).
[[nodiscard]] auto getState() const -> State { return state_; }
[[nodiscard]] auto isAirborne() const -> bool { return state_ == State::AIRBORNE; }
[[nodiscard]] auto isHurt() const -> bool { return state_ == State::HURT; }
// Tangible per a contactes jugador↔jugador: només els estats a terra
// amb cos sòlid. AIRBORNE i HURT són intangibles (es travessen).
[[nodiscard]] auto isPvpTangible() const -> bool {
return state_ == State::GROUNDED || state_ == State::LANDING_STUN;
}
// Alçada actual del salt (píxels visuals per damunt del sòl).
// 0 quan està a terra. L'usa el render i el sistema de col·lisions
@@ -385,6 +398,27 @@ class Player : public AnimatedSprite {
// manca, deixa la mecànica del salt desactivada.
static void readJumpBlock(const fkyaml::node& yaml);
// Configuració de l'estat HURT (estat post-impacte). Si
// `initial_speed_px_per_s == 0` el jugador entra i surt
// instantàniament (cap moviment).
struct HurtConfig {
float initial_speed_px_per_s{0.0F}; // Velocitat amb què surt projectat (px/s).
int brake_time_ms{0}; // Temps en que la velocitat decau a 0.
};
static HurtConfig hurt_config;
// Llig el bloc `hurt:` del YAML i n'omple `hurt_config`.
static void readHurtBlock(const fkyaml::node& yaml);
// Activa l'estat HURT: el jugador queda projectat amb velocitat
// inicial `hurt_config.initial_speed_px_per_s` cap a la direcció que
// doni més recorregut lliure (provem els 4 cardinals i triem el de
// major distància fins a topar amb un mur). Decelera linealment fins
// a parar i, en eixe instant, torna a GROUNDED. Sense col·lisió amb
// altres jugadors mentre dura — ja que es travessa l'eventual
// solapament inicial.
void enterHurt();
// Estat de la màquina d'estats. Mutat per `tick`/`applyMovement`. Per
// defecte GROUNDED al spawn.
State state_{State::GROUNDED};
@@ -418,17 +452,23 @@ class Player : public AnimatedSprite {
// `applyMovement` puga disparar la resolució del solapament PvP).
auto integrateJump(float delta_time) -> bool;
// En aterrar, si la hit-box del jugador solapa amb la d'un altre,
// separa els dos sense ficar-los dins d'un mur. Algoritme:
// 1. Calcula la direcció centre→centre i la magnitud de
// solapament (MTV axis-aligned).
// 2. Empenta l'altre cap a fora; si no cap (mur al darrere),
// reverteix i empenta el saltador cap al sentit oposat.
// 3. Si tampoc cap, es deixa l'estat com està (overlap residual);
// a la pràctica aquest cas és molt rar i el usuari ja ha
// acceptat que no és crític.
// En aterrar, si la base-box del jugador solapa amb la d'un altre,
// l'altre entra en `HURT` i surt projectat per `enterHurt`. El
// saltador (this) es queda al lloc — el moviment per separar-los
// el fa el HURT en cours, no una empenta forçada.
void resolveLandingOverlap();
// Branques de `applyMovement` extretes per acotar la complexitat
// cognitiva. Cada estat especial gestiona el seu propi cicle
// (animació, tryMove o no, publicació de tile).
void applyHurtMovement(float delta_time);
void applyLandingStunMovement(float delta_time);
// Publica `on_tile_entered` al bus si el tile ha canviat respecte a
// l'últim publicat. Comú a totes les branques de `applyMovement` que
// pinten el sòl (GROUNDED, HURT, LANDING_STUN; en AIRBORNE no).
void publishTileEnteredIfChanged();
// Estat del gir en curs. Mentre `turning_remaining_ms_ > 0`, el facing/
// flip es manté forçat a `intermediate_cardinal_` i l'animació es força
// a `stand` encara que el moviment continuï. En esgotar-se el timer,
+5
View File
@@ -106,6 +106,11 @@ namespace {
const bool A_AIR = a->isAirborne();
const bool B_AIR = b->isAirborne();
// HURT a un costat o l'altre: el resolver no s'activa — el HURT
// surt projectat sense control i els dos es travessen. Coincideix
// amb la regla d'`Player::collidesWithWorld`.
if (a->isHurt() || b->isHurt()) { return; }
// Aire ↔ tierra: el saltador es travessa net del caminant. Sense
// resolver, sense head-on, sense push. Els dos continuen amb la
// velocitat que portaven. Important: això NO és el filtre per