diff --git a/data/player/morcus.yaml b/data/player/morcus.yaml index 9f6806d..aaa4105 100644 --- a/data/player/morcus.yaml +++ b/data/player/morcus.yaml @@ -68,6 +68,20 @@ animations: frames: [8, 9, 10, 11] frame_ms: 100 + # Placeholder: igual que sam.yaml, reaprofitem `walk_*` fins que + # arribe l'art específic del braç llançant el látigo. + attack_down: + frames: [0, 1, 2, 3] + frame_ms: 50 + + attack_up: + frames: [4, 5, 6, 7] + frame_ms: 50 + + attack: + frames: [8, 9, 10, 11] + frame_ms: 50 + # Inèrcia només de frenada. L'acceleració és sempre instantània (soltar # el teclat + prémer una direcció = velocitat plena); en canvi, quan es # solta la direcció el jugador decau linealment a 0 en `brake_time_ms`. @@ -127,3 +141,11 @@ jump: hurt: speed: 120 brake_time_ms: 500 + +# Configuració del látigo. Mateixos valors que Sam de moment; +# diferenciar quan equilibrem cada personatge. +whip: + range_tiles: 3 + swing_ms: 200 + cooldown_ms: 200 + damage: 1 diff --git a/data/player/sam.yaml b/data/player/sam.yaml index 0b22f7f..2c4c3ac 100644 --- a/data/player/sam.yaml +++ b/data/player/sam.yaml @@ -68,6 +68,22 @@ animations: frames: [8, 9, 10, 11] frame_ms: 100 + # Animacions del braç llançant el látigo. Placeholder: reaprofiten els + # frames de `walk_*` perquè els sprites reals encara no existeixen + # (TODO: dibuixar-ne d'específics quan arribe l'art). El sprite del + # látigo viu al `WhipAttack` i es renderitza superposat per z-order. + attack_down: + frames: [0, 1, 2, 3] + frame_ms: 50 + + attack_up: + frames: [4, 5, 6, 7] + frame_ms: 50 + + attack: + frames: [8, 9, 10, 11] + frame_ms: 50 + # Inèrcia només de frenada. L'acceleració és sempre instantània (soltar # el teclat + prémer una direcció = velocitat plena); en canvi, quan es # solta la direcció el jugador decau linealment a 0 en `brake_time_ms`. @@ -127,3 +143,16 @@ jump: hurt: speed: 120 brake_time_ms: 500 + +# Configuració del látigo. `range_tiles` és la longitud de la hitbox +# abans de retallar contra murs/sarcs (3 tiles ≈ 48 px). `swing_ms` és +# la durada de l'animació de l'atac, durant la qual la hitbox del whip +# està viva. `cooldown_ms` allarga ATTACKING amb una recuperació en què +# el jugador segueix bloquejat però la hitbox ja ha desaparegut: castiga +# l'ús abusiu del látigo. `damage` queda llegit per a futur (els +# objectius reben `enterHurt` directament; cap entitat té HP encara). +whip: + range_tiles: 3 + swing_ms: 200 + cooldown_ms: 200 + damage: 1 diff --git a/source/core/rendering/sprite/moving_sprite.hpp b/source/core/rendering/sprite/moving_sprite.hpp index f537361..8f97f91 100644 --- a/source/core/rendering/sprite/moving_sprite.hpp +++ b/source/core/rendering/sprite/moving_sprite.hpp @@ -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 diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index 5efb701..7b9af55 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -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(); } } +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(); } + if (w.contains("swing_ms")) { whip_config.swing_ms = w["swing_ms"].get_value(); } + if (w.contains("cooldown_ms")) { whip_config.cooldown_ms = w["cooldown_ms"].get_value(); } + if (w.contains("damage")) { whip_config.damage = w["damage"].get_value(); } +} + 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(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(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(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(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"; diff --git a/source/game/entities/player.hpp b/source/game/entities/player.hpp index cd3b3db..773d2bb 100644 --- a/source/game/entities/player.hpp +++ b/source/game/entities/player.hpp @@ -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(); diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index 3f05172..e9f9129 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -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 diff --git a/source/utils/defaults.hpp b/source/utils/defaults.hpp index 739056b..7f03f7f 100644 --- a/source/utils/defaults.hpp +++ b/source/utils/defaults.hpp @@ -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 {