feat(player): estat ATTACKING amb edge-detect, animacions i spawn del whip via bus

This commit is contained in:
JailDesigner committed 2026-04-28 16:20:22 +02:00
1 parent 5db3bb0bd0
commit d208ba02df
7 files changed
+288 -1

No files matched your search

@@ -38,7 +38,7 @@ class MovingSprite : public Sprite {
void setAccelY(float value) { ay_ = value; }
void setFlip(SDL_FlipMode flip) { flip_ = flip; }
auto getFlip() -> SDL_FlipMode { return flip_; }
[[nodiscard]] auto getFlip() const -> SDL_FlipMode { return flip_; }
void flip() { flip_ = (flip_ == SDL_FLIP_HORIZONTAL) ? SDL_FLIP_NONE : SDL_FLIP_HORIZONTAL; }
// Moviment eix-separat amb col·lisió: aplica `dx` (i si la nova
+177
View File
@@ -68,6 +68,7 @@ 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;
void Player::loadFrom(const std::string& yaml_path) {
auto bytes = Resource::Helper::loadFile(yaml_path);
@@ -114,6 +115,11 @@ void Player::loadFrom(const std::string& yaml_path) {
// 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);
}
void Player::readCollisionBlock(const fkyaml::node& yaml, const std::string& yaml_path) {
@@ -175,6 +181,16 @@ void Player::readHurtBlock(const fkyaml::node& yaml) {
if (h.contains("brake_time_ms")) { hurt_config.brake_time_ms = h["brake_time_ms"].get_value<int>(); }
}
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>(); }
}
void Player::applyCardinal(Cardinal c) {
switch (c) {
case Cardinal::NORTH:
@@ -440,6 +456,7 @@ void Player::tick(float delta_time) {
// 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;
}
@@ -449,11 +466,36 @@ void Player::tick(float delta_time) {
// 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.
@@ -461,6 +503,7 @@ void Player::tick(float delta_time) {
// 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;
@@ -535,6 +578,18 @@ void Player::tick(float delta_time) {
// 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
@@ -565,6 +620,92 @@ auto Player::consumeJumpEdge() -> bool {
return edge;
}
auto Player::consumeAttackEdge() -> bool {
// Mateix patró que `consumeJumpEdge` però per al botó d'atac. Mando:
// SDL_GAMEPAD_BUTTON_WEST (X a Xbox / Quadrat a PlayStation), oposat
// a SOUTH per evitar topades semàntiques.
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 bool PAD_NOW = (pad != nullptr) && InputState::getGamepadButton(pad, SDL_GAMEPAD_BUTTON_WEST);
if (PAD_NOW && !attack_held_gamepad_) { edge = true; }
attack_held_gamepad_ = PAD_NOW;
return edge;
}
auto Player::currentFacingCardinal() const -> Cardinal {
switch (facing_) {
case Facing::UP:
return Cardinal::NORTH;
case Facing::DOWN:
return Cardinal::SOUTH;
case Facing::SIDE:
break;
}
// Lateral: el flip discrimina E/W. Sense flip = mira a la dreta
// (convenció estable a tots els assets actuals).
return (getFlip() == SDL_FLIP_HORIZONTAL) ? Cardinal::WEST : 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);
// Geometria de spawn de la hitbox: alineada al rectangle de
// col·lisió del jugador, no al sprite (que té perspectiva falsa amb
// el cap dalt). La hitbox és `range_px × tile_size` (E/W) o
// `tile_size × range_px` (N/S); el GameScene la consumeix per
// crear el WhipAttack amb la mida adequada per direcció.
const auto BOX = getBaseBox();
const float RANGE_PX = whip_config.range_tiles * static_cast<float>(Map::tile_size);
float spawn_x = 0.0F;
float spawn_y = 0.0F;
int dir_int = 0;
switch (facing) {
case Cardinal::EAST:
spawn_x = BOX.x + BOX.w;
spawn_y = BOX.y;
dir_int = 3;
break;
case Cardinal::WEST:
spawn_x = BOX.x - RANGE_PX;
spawn_y = BOX.y;
dir_int = 4;
break;
case Cardinal::NORTH:
spawn_x = BOX.x;
spawn_y = BOX.y - RANGE_PX;
dir_int = 1;
break;
case Cardinal::SOUTH:
spawn_x = BOX.x;
spawn_y = BOX.y + BOX.h;
dir_int = 2;
break;
case Cardinal::NONE:
return;
}
if (bus_ != nullptr) {
bus_->publish("whip_spawn", WhipSpawnPayload{
.owner = this,
.direction = dir_int,
.spawn_x = spawn_x,
.spawn_y = spawn_y,
.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),
@@ -788,6 +929,15 @@ void Player::applyMovement(float 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.
@@ -989,6 +1139,33 @@ auto Player::animationName(bool moving) const -> const char* {
}
return "hurt";
}
// ATTACKING té dues sub-fases: durant `swing_ms` el sprite és
// `attack_*` (animació del braç llançant el látigo); durant
// `cooldown_ms` posterior tornem al `stand_*` de la direcció. Fem
// la decisió comparant el remaining_ms_ amb el cooldown configurat.
if (state_ == State::ATTACKING) {
const bool IN_SWING = (attacking_remaining_ms_ > static_cast<float>(whip_config.cooldown_ms));
if (IN_SWING) {
switch (facing_) {
case Facing::UP:
return "attack_up";
case Facing::DOWN:
return "attack_down";
case Facing::SIDE:
break;
}
return "attack";
}
switch (facing_) {
case Facing::UP:
return "stand_up";
case Facing::DOWN:
return "stand_down";
case Facing::SIDE:
break;
}
return "stand";
}
switch (facing_) {
case Facing::UP:
return moving ? "walk_up" : "stand_up";
+55
View File
@@ -24,6 +24,7 @@ class Player : public AnimatedSprite {
SDL_Scancode left;
SDL_Scancode right;
SDL_Scancode jump;
SDL_Scancode attack;
};
// Estat d'alt nivell del jugador. Ortogonal a `Facing`/`Cardinal`
@@ -44,6 +45,15 @@ class Player : public AnimatedSprite {
AIRBORNE,
LANDING_STUN,
HURT,
// Atacant amb látigo. Estat mútuament exclusiu amb AIRBORNE: no
// es pot atacar saltant, ni saltar atacant. Cobreix swing +
// cooldown internament: durant `whip_swing_ms` es spawneja
// l'entitat WhipAttack i corre l'animació `attack_*`; durant
// `whip_cooldown_ms` posterior, el jugador segueix bloquejat
// (no input, no salt, no atac) però visualment torna a
// `stand_*`. En total, ATTACKING dura `swing_ms + cooldown_ms`;
// en acabar, torna a GROUNDED.
ATTACKING,
// Jugador que ha eixit per la porta de la sala. Despawn lògic:
// no es renderitza, no processa input, no es mou, no col·lisiona
// amb res ni rep contacte d'entitats. Idempotent: les crides
@@ -503,6 +513,37 @@ class Player : public AnimatedSprite {
// Llig el bloc `hurt:` del YAML i n'omple `hurt_config`.
static void readHurtBlock(const fkyaml::node& yaml);
// Configuració del látigo del jugador llegida del bloc `whip:` del
// YAML. `range_tiles` defineix la longitud de la hitbox abans de
// retallar; `swing_ms` és la durada de l'animació d'atac (des
// d'`enterAttacking` fins a desbloquejar el cooldown); `cooldown_ms`
// afegeix una fase posterior durant la qual el jugador segueix
// bloquejat però la hitbox ja no fa dany. `damage` queda llegit per
// a futur; per ara els objectius reben sempre `enterHurt`.
struct WhipConfig {
float range_tiles{0.0F};
int swing_ms{0};
int cooldown_ms{0};
int damage{0};
};
static WhipConfig whip_config;
// Llig el bloc `whip:` del YAML i n'omple `whip_config`. Si manca el
// bloc, els camps queden a 0 i el jugador no pot atacar (el flanc
// d'input es perd silenciosament).
static void readWhipBlock(const fkyaml::node& yaml);
// Activa l'estat ATTACKING en la direcció cardinal indicada (la
// direcció cap on mira el jugador) i publica `whip_spawn` al bus
// perquè el GameScene cree el `WhipAttack` corresponent. Posa el
// timer `attacking_remaining_ms_` a `swing_ms + cooldown_ms`.
void enterAttacking(Cardinal facing);
// Cardinal que correspon al `(facing_, flip_)` actual del sprite —
// la direcció cap on mira el jugador. Si `facing_ == SIDE` el
// resultat depèn del flip; UP/DOWN són trivials. Mai retorna NONE.
[[nodiscard]] auto currentFacingCardinal() const -> Cardinal;
// Activa l'estat HURT: el jugador queda projectat amb velocitat
// inicial `hurt_config.initial_speed_px_per_s`. La direcció es tria
// així:
@@ -549,10 +590,24 @@ class Player : public AnimatedSprite {
bool jump_held_keyboard_{false};
bool jump_held_gamepad_{false};
// Mateixa idea per al botó d'atac: edge-detection independent
// teclat/mando, perquè mantenir la tecla no genere atacs en cadena.
bool attack_held_keyboard_{false};
bool attack_held_gamepad_{false};
// Temps que queda dins de l'estat ATTACKING (swing + cooldown). Quan
// arriba a 0, transició a GROUNDED. Mentre dura, el jugador queda
// bloquejat i no rep input direccional ni de salt ni d'atac.
float attacking_remaining_ms_{0.0F};
// Llig el botó de salt aquest frame (teclat + mando assignat) i,
// amb edge-detection, retorna true si s'ha de despegar el salt.
[[nodiscard]] auto consumeJumpEdge() -> bool;
// Anàleg per a l'atac: edge-detection del botó d'atac (teclat
// `controls_.attack` + `SDL_GAMEPAD_BUTTON_WEST`).
[[nodiscard]] auto consumeAttackEdge() -> bool;
// Inicia un salt: copia `intended_v*` com a velocitat fixada del salt,
// posa `jump_vz_ = impulse`, transiciona a AIRBORNE.
void startJump();
+2
View File
@@ -335,6 +335,7 @@ GameScene::GameScene(Config config)
.left = Defaults::KeysGame::LEFT,
.right = Defaults::KeysGame::RIGHT,
.jump = Defaults::KeysGame::JUMP,
.attack = Defaults::KeysGame::ATTACK,
};
const Player::Controls CONTROLS_P2{
.up = Defaults::KeysGameP2::UP,
@@ -342,6 +343,7 @@ GameScene::GameScene(Config config)
.left = Defaults::KeysGameP2::LEFT,
.right = Defaults::KeysGameP2::RIGHT,
.jump = Defaults::KeysGameP2::JUMP,
.attack = Defaults::KeysGameP2::ATTACK,
};
// Spawn lligat a la porta: tots els jugadors apareixen davant del
// passadís de sortida. `computeSpawn` deriva les coordenades de
+2
View File
@@ -78,6 +78,7 @@ namespace Defaults {
constexpr SDL_Scancode LEFT = SDL_SCANCODE_LEFT;
constexpr SDL_Scancode RIGHT = SDL_SCANCODE_RIGHT;
constexpr SDL_Scancode JUMP = SDL_SCANCODE_M;
constexpr SDL_Scancode ATTACK = SDL_SCANCODE_N;
constexpr SDL_Scancode EXIT = SDL_SCANCODE_ESCAPE;
} // namespace KeysGame
@@ -91,6 +92,7 @@ namespace Defaults {
constexpr SDL_Scancode LEFT = SDL_SCANCODE_A;
constexpr SDL_Scancode RIGHT = SDL_SCANCODE_D;
constexpr SDL_Scancode JUMP = SDL_SCANCODE_V;
constexpr SDL_Scancode ATTACK = SDL_SCANCODE_C;
} // namespace KeysGameP2
namespace Player {