feat(player): nou estat HURT — caure damunt d'un altre el llança projectat
This commit is contained in:
1 parent
188f035333
commit
e8407ce35e
5 files changed
+310
-119
No files matched your search
+203
-105
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user