Merge branch 'feat/whip-attack' — atac de làtigo (Player ATTACKING, Mummy HURT, hitbox amb retall)

This commit is contained in:
JailDesigner committed 2026-04-28 18:44:13 +02:00
commit ce55aeeadc
26 files changed
+1588 -6

No files matched your search

+35 -2
View File
@@ -33,7 +33,10 @@ configure_file(${CMAKE_SOURCE_DIR}/source/version.h.in ${CMAKE_BINARY_DIR}/versi
# Sources comuns del sistema d'entitats: s'afegeixen tant a l'executable
# principal com al smoke test. Mantenir-los en variable separada evita
# duplicació entre els dos targets.
set(ENTITY_SOURCES
# Entitats sense dependència directa amb Player.cpp: les enllacen tant
# l'executable principal com el test_entities_smoke (que evita arrossegar
# l'stack del jugador i les seues dependències pesades).
set(ENTITY_SOURCES_BASE
source/game/entities/entity.cpp
source/game/entities/animation_component.cpp
source/game/entities/event_bus.cpp
@@ -50,6 +53,13 @@ set(ENTITY_SOURCES
source/game/entities/concrete/dust.cpp
)
# Entitats que criden directament símbols de Player (com WhipAttack via
# `enterHurt`/`getBaseBox`): només van a l'executable principal.
set(ENTITY_SOURCES
${ENTITY_SOURCES_BASE}
source/game/entities/concrete/whip_attack.cpp
)
set(APP_SOURCES
source/main.cpp
source/core/system/director.cpp
@@ -338,7 +348,7 @@ if(BUILD_TESTS)
source/core/resources/resource_helper.cpp
source/core/resources/resource_pack.cpp
source/game/map/map.cpp
${ENTITY_SOURCES}
${ENTITY_SOURCES_BASE}
)
target_include_directories(test_entities_smoke PRIVATE
"${CMAKE_SOURCE_DIR}/source"
@@ -351,6 +361,29 @@ if(BUILD_TESTS)
COMMAND test_entities_smoke
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})
# Smoke del WhipAttack. Inclou tot l'stack d'entitats + whip_attack.cpp.
# `whip_attack_player_stubs.cpp` proveeix símbols mínims de Player per
# satisfer el linker sense arrossegar audio/gamepad/screen — els tests
# del whip s'enfoquen a hitbox + Mummy, no a friendly fire.
add_executable(test_whip_attack
tests/test_whip_attack.cpp
tests/whip_attack_player_stubs.cpp
source/core/resources/resource_helper.cpp
source/core/resources/resource_pack.cpp
source/game/map/map.cpp
${ENTITY_SOURCES_BASE}
source/game/entities/concrete/whip_attack.cpp
)
target_include_directories(test_whip_attack PRIVATE
"${CMAKE_SOURCE_DIR}/source"
"${CMAKE_BINARY_DIR}"
)
target_link_libraries(test_whip_attack PRIVATE SDL3::SDL3)
target_compile_options(test_whip_attack PRIVATE -Wall -Wextra -Wpedantic)
add_test(NAME whip_attack
COMMAND test_whip_attack
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})
# Smoke del flux d'escenes i del parser de debug.yaml. Stand-alone:
# només enllaça els helpers purs (debug_config, placeholder_input,
# key_config) perquè la integració completa dels placeholders arrossegaria
+10
View File
@@ -38,3 +38,13 @@ ai:
stats:
speed: 30
damage: 1
# Estat HURT: post-fuetada, fugint en línia recta. Si la direcció
# preferida (la del whip) està bloquejada, prova perpendiculars; si totes
# ho estan, queda quieta tot el `duration_ms` i després torna a ROAMING.
# `speed` opcional: si no apareix, s'usa `stats.speed`. El `whippable`
# habilita la reacció al látigo (false ⇒ el whip la ignora).
hurt:
duration_ms: 600
speed: 60
whippable: true
@@ -0,0 +1,38 @@
name: whip_attack
# Quatre sprites distints, un per cardinal. El sistema d'animació actual
# només permet UNA mida de frame per surface, i els sprites del látigo
# tenen aspect ratios distints segons direcció. Per això són 4 fitxers
# en lloc d'una sola sheet compartida amb flips.
#
# Mides (= `draw_rect` del WhipAttack, que NO es retalla per
# bloquejadors):
# E/W → 80×32 px per frame: 16 px de wind-up darrere del jugador
# + 16 px de solapament + 48 px d'atac
# frontal. Cobreix tota l'altura del jugador.
# N/S → 16×96 px per frame: 48 px d'atac + 32 px solapant amb el jugador
# + 16 px de wind-up "darrere".
#
# La caja de DANY és independent i més petita (48×16 / 16×48), pegada
# al borde del jugador i retallada per murs/sarcs. La calcula
# `WhipAttack::configure` a partir del `owner_box` del payload.
sprites:
east: { path: whip_east.gif, size: [80, 32] }
west: { path: whip_west.gif, size: [80, 32] }
north: { path: whip_north.gif, size: [16, 96] }
south: { path: whip_south.gif, size: [16, 96] }
# Animació compartida entre les 4 direccions: la seqüència de frames i les
# durades són idèntiques; només canvia quina surface/mida es clipa.
# - `frames` : índex de tile dins l'spritesheet (graella deduïda).
# - `frame_ms` : durada uniforme per frame; 50 ms × 4 ≈ 200 ms swing.
# - `loop: false` : el látigo viu una animació i mor (`markDead`).
# - `active_frames` : valors dins de `frames` durant els quals la
# hitbox aplica dany. Wind-up (0) i recovery (3) no
# danyen — només els frames "actius" 1 i 2.
animations:
swing:
frames: [0, 1, 2, 3]
frame_ms: 50
loop: false
active_frames: [1, 2]
Binary file not shown.

After

Width:  |  Height:  |  Size: 313 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 B

+38
View File
@@ -68,6 +68,36 @@ animations:
frames: [8, 9, 10, 11]
frame_ms: 100
# Placeholder mentre no arribe l'art real (el GIF de morcus encara no té
# frames d'atac específics — reaprofitem `walk_*`). El bloc està declarat
# perquè `Player::animationName` el busque sense fallback i la mecànica
# de l'atac estiga lligada amb morcus tan bon punt s'afegisquen els
# frames; ara mateix visualment el sprite caminarà mentre llança el
# látigo (acceptat per al placeholder).
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
# Cooldown post-swing (recovery): igual que a sam.yaml, placeholder als
# frames de `stand_*`. Quan arriben els frames reals només cal canviar
# els índexs ací.
recover_down:
frames: [0]
recover_up:
frames: [4]
recover:
frames: [8]
# 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 +157,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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

+43
View File
@@ -68,6 +68,36 @@ 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: [28, 29, 30, 31]
frame_ms: 50
attack_up:
frames: [24, 25, 26, 27]
frame_ms: 50
attack:
frames: [20, 21, 22, 23]
frame_ms: 50
# Animacions de cooldown post-swing (recovery): mentre dura
# `whip_cooldown_ms` el jugador continua bloquejat però l'animació ja
# no és la d'atac. Placeholder: reaprofiten els frames de `stand_*`
# (la silueta no canvia respecte d'ells); quan arriben els frames
# reals només cal canviar els índexs ací, sense tocar el codi.
recover_down:
frames: [1]
recover_up:
frames: [5]
recover:
frames: [9]
# 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 +157,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
@@ -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
+35
View File
@@ -0,0 +1,35 @@
#pragma once
#include "game/entities/entity.hpp"
class Player;
// Base d'entitats d'atac: spawnejades pels jugadors, viuen poc temps i
// poden danyar `Hazard`s i altres `Player`s (mai el seu propi `owner_`).
//
// Branca paral·lela a `Hazard` (no hereta d'ella) perquè la semàntica és
// inversa: un `Hazard` és un perill estàtic/dinàmic que toca el jugador,
// mentre que un `Attack` és el jugador qui colpeja la resta. Mantenir-les
// separades permet, p. ex., que un atac no aparega al test de "què colpeja
// al Player en el seu rect" (`EntityManager::checkPlayerCollisions`) perquè
// el sentit del filtre és un altre.
//
// `Attack` no col·lisiona al test de moviment (els atacs no bloquegen
// passos). El càlcul de quins objectius reben dany el fa cada subclasse
// segons la seua geometria (rectangle direccional per al látigo, cercle
// per a una explosió de bomba en el futur, etc.).
class Attack : public Entity {
public:
Attack() {
collides_ = false;
interactable_ = false;
}
void setOwner(Player* owner) { owner_ = owner; }
[[nodiscard]] auto getOwner() const -> Player* { return owner_; }
protected:
// Jugador que ha llançat l'atac. No rep dany del seu propi atac
// (anti-self-friendly-fire). `nullptr` només a tests sintètics.
Player* owner_{nullptr};
};
+130
View File
@@ -104,6 +104,22 @@ Mummy::Mummy(const fkyaml::node& yaml)
} else if (initial == "down") {
changeDirection(0.0F, 1.0F);
}
// Configuració de l'estat HURT: opcional. Si el bloc no apareix, la
// mòmia no entrarà mai a HURT efectiu (defensiu); el WhipAttack la
// pot ignorar amb `isWhippable() == false` si volem que no reacció.
if (yaml.contains("hurt")) {
const auto& h = yaml["hurt"];
if (h.contains("duration_ms")) {
hurt_config_.duration_s = static_cast<float>(h["duration_ms"].get_value<int>()) / 1000.0F;
}
if (h.contains("speed")) {
hurt_config_.speed_px_per_s = h["speed"].get_value<float>();
}
if (h.contains("whippable")) {
whippable_ = h["whippable"].get_value<bool>();
}
}
}
void Mummy::changeDirection(float dx, float dy) {
@@ -305,4 +321,118 @@ void Mummy::onPlayerContact(Player* player) {
}
}
void Mummy::enterHurt(Direction flee_dir) {
state_ = State::HURT;
hurt_remaining_s_ = hurt_config_.duration_s;
hurt_speed_ = (hurt_config_.speed_px_per_s > 0.0F) ? hurt_config_.speed_px_per_s : speed_;
const Direction CHOSEN = chooseHurtDirection(flee_dir);
if (CHOSEN == Direction::NONE) {
// Cap direcció lliure: la mòmia queda quieta però en HURT durant
// tot el timer. Posem dir a 0 perquè `hurtStep` no es mou.
setDirection(0.0F, 0.0F);
} else {
applyChoice(CHOSEN);
}
}
auto Mummy::chooseHurtDirection(Direction preferred) const -> Direction {
// Avenç ~1 frame a 60 FPS amb la velocitat de HURT. Tan curt com
// perquè un canvi sub-tile no decideixe; suficient perquè un mur
// adjacent (o sarc tocant la caja) es detecte.
const float TEST_SPEED = (hurt_config_.speed_px_per_s > 0.0F) ? hurt_config_.speed_px_per_s : speed_;
const float STEP = TEST_SPEED * (1.0F / 60.0F);
auto is_open = [this, STEP](Direction d) -> bool {
float dx = 0.0F;
float dy = 0.0F;
switch (d) {
case Direction::UP:
dy = -STEP;
break;
case Direction::DOWN:
dy = STEP;
break;
case Direction::LEFT:
dx = -STEP;
break;
case Direction::RIGHT:
dx = STEP;
break;
case Direction::NONE:
return false;
}
const auto BOX = candidateBox(dx, dy);
return Map::insidePlayfield(BOX) && !Map::collides(BOX);
};
if (preferred != Direction::NONE && is_open(preferred)) { return preferred; }
// Perpendiculars segons la direcció preferida (ordre arbitrari però
// consistent: "esquerra del whip" primer).
Direction left = Direction::NONE;
Direction right = Direction::NONE;
switch (preferred) {
case Direction::UP:
left = Direction::LEFT;
right = Direction::RIGHT;
break;
case Direction::DOWN:
left = Direction::RIGHT;
right = Direction::LEFT;
break;
case Direction::LEFT:
left = Direction::DOWN;
right = Direction::UP;
break;
case Direction::RIGHT:
left = Direction::UP;
right = Direction::DOWN;
break;
case Direction::NONE:
return Direction::NONE;
}
if (is_open(left)) { return left; }
if (is_open(right)) { return right; }
return Direction::NONE;
}
void Mummy::hurtStep(float delta_time) {
// Si està quieta (totes les direccions bloquejades a `enterHurt`), no
// avança ni surt: l'únic camí de sortida és el timer.
if (dir_x_ == 0.0F && dir_y_ == 0.0F) { return; }
const float DX = dir_x_ * hurt_speed_ * delta_time;
const float DY = dir_y_ * hurt_speed_ * delta_time;
const auto CANDIDATE = candidateBox(DX, DY);
if (Map::insidePlayfield(CANDIDATE) && !Map::collides(CANDIDATE)) {
pixel_rect_.x += DX;
pixel_rect_.y += DY;
return;
}
// Topa amb mur o sarc: surt del HURT immediatament. La IA normal
// re-decideix al pròxim step (no fem U-turn ni re-orientació aquí).
state_ = State::ROAMING;
hurt_remaining_s_ = 0.0F;
}
void Mummy::update(float delta_time) {
if (state_ == State::HURT) {
// Avancem l'animació inline (no podem cridar `Entity::update` ni
// `DynamicHazard::update`: el primer salta una capa virtual i el
// segon dispararia `step()` amb la lògica d'IA). Volem només el
// tick d'animació + el moviment recta del HURT.
if (anim_.has_value()) { anim_->tick(delta_time); }
hurtStep(delta_time);
hurt_remaining_s_ -= delta_time;
if (hurt_remaining_s_ <= 0.0F && state_ == State::HURT) {
state_ = State::ROAMING;
}
return;
}
DynamicHazard::update(delta_time);
}
REGISTER_ENTITY("mummy", Mummy)
+47
View File
@@ -21,6 +21,21 @@ class Mummy : public DynamicHazard {
LEFT,
RIGHT };
// Estat d'alt nivell. ROAMING = IA normal (corridor_wanderer); HURT =
// post-fuetada, fugint en línia recta durant `hurt_config_.duration_s`.
// Mentre dura el HURT no decideix a creuaments ni canvia de direcció;
// si topa amb mur o sarc, surt del HURT abans de l'expiració del timer.
enum class State : std::uint8_t { ROAMING,
HURT };
// Configuració de l'estat HURT, llegida del bloc `hurt:` del YAML.
// Si `duration_s == 0` la mòmia mai entra a HURT efectiu (defensiu).
// `speed_px_per_s == 0` ⇒ s'usa la velocitat normal de la mòmia.
struct HurtConfig {
float duration_s{0.0F};
float speed_px_per_s{0.0F};
};
explicit Mummy(const fkyaml::node& yaml);
// Hook d'IA. Donat un conjunt de direccions admissibles, decideix la
@@ -39,9 +54,22 @@ class Mummy : public DynamicHazard {
// En ambdós casos el frame de gir no consumeix avenç extra.
void step(float delta_time) override;
// Update sencer: afegim la branca HURT (avanç del timer + step recta).
// En estat ROAMING delega a `step()` com sempre via la base.
void update(float delta_time) override;
// Contacte amb el jugador: publica `player_hit` amb el dany.
void onPlayerContact(Player* player) override;
// Activa l'estat HURT amb la direcció de fuga preferida `flee_dir`.
// Si està bloquejada, prova perpendiculars; si totes estan bloquejades,
// la mòmia queda quieta però en HURT durant tot el timer.
void enterHurt(Direction flee_dir);
[[nodiscard]] auto getState() const -> State { return state_; }
[[nodiscard]] auto isWhippable() const -> bool { return whippable_; }
[[nodiscard]] auto getHurtRemainingS() const -> float { return hurt_remaining_s_; }
private:
// Caixa de col·lisió candidata si l'entitat es desplaçara (dx, dy).
[[nodiscard]] auto candidateBox(float dx, float dy) const -> SDL_FRect;
@@ -90,9 +118,28 @@ class Mummy : public DynamicHazard {
// S'usa al pool de creuament per afegir la recta com a candidata.
[[nodiscard]] auto currentDirection() const -> Direction;
// Avança recta a `hurt_speed_ * delta_time`. Si la caixa candidata
// sortiria del playfield o trepitjaria un sarc, marca la sortida del
// HURT abans de l'expiració del timer (la mòmia no rebota).
void hurtStep(float delta_time);
// Tria la direcció definitiva del HURT donada la preferida (la del
// whip). Si la preferida està bloquejada, prova perpendiculars; si
// totes ho estan, retorna `NONE` (la mòmia queda quieta en HURT).
[[nodiscard]] auto chooseHurtDirection(Direction preferred) const -> Direction;
// Animacions carregades del YAML, una per cada facing. Per a horitzontal
// només en cal una (`walk`) i el flip ja diferencia esquerra/dreta.
AnimationComponent anim_walk_down_;
AnimationComponent anim_walk_up_;
AnimationComponent anim_walk_lateral_;
// Estat HURT i la seua configuració. `hurt_speed_` és l'efectiva
// (resultat d'aplicar el `speed` del bloc `hurt:` o, si val 0, la
// velocitat normal de la mòmia).
State state_{State::ROAMING};
HurtConfig hurt_config_;
bool whippable_{true};
float hurt_remaining_s_{0.0F};
float hurt_speed_{0.0F};
};
@@ -0,0 +1,342 @@
#include "game/entities/concrete/whip_attack.hpp"
#include <algorithm>
#include <filesystem>
#include <string>
#include <vector>
#include "game/entities/concrete/mummy.hpp"
#include "game/entities/entity_factory.hpp"
#include "game/entities/entity_manager.hpp"
#include "game/entities/player.hpp"
#include "game/map/map.hpp"
namespace {
// Llig una entrada `sprites.<dir>: { path, size: [w, h] }` del YAML.
// Si manquen camps, retorna un DirSprite buit (queda invalidat al
// configure() per `surface_key.empty()`).
auto readDirSprite(const fkyaml::node& s) -> WhipAttack::DirSprite {
WhipAttack::DirSprite sprite;
if (s.contains("path")) {
const auto REL = s["path"].get_value<std::string>();
sprite.surface_key = std::filesystem::path(REL).filename().string();
}
if (s.contains("size")) {
const auto& sz = s["size"];
if (sz.is_sequence() && sz.size() == 2) {
sprite.frame_w = static_cast<float>(sz[0].get_value<int>());
sprite.frame_h = static_cast<float>(sz[1].get_value<int>());
}
}
return sprite;
}
// Construeix l'`AnimationComponent` del swing a partir del bloc
// `animations.swing`. Convenció igual que la resta d'entitats:
// - `frames` : llista d'índexs dins l'spritesheet (obligatori).
// - `frame_ms` : durada uniforme per frame (ms). Alternativament `fps`.
// - `loop` : per defecte true; al látigo el volem false (mor en
// completar).
auto buildSwingAnim(const fkyaml::node& yaml, std::vector<int>& active_out)
-> AnimationComponent {
std::vector<int> frames = {0};
float fps = 8.0F;
bool loop = false;
if (yaml.contains("animations") && yaml["animations"].contains("swing")) {
const auto& sw = yaml["animations"]["swing"];
if (sw.contains("frames")) {
frames.clear();
for (const auto& f : sw["frames"]) {
// cppcheck-suppress useStlAlgorithm
frames.push_back(f.get_value<int>());
}
if (frames.empty()) { frames.push_back(0); }
}
if (sw.contains("frame_ms")) {
const auto MS = static_cast<float>(sw["frame_ms"].get_value<int>());
if (MS > 0.0F) { fps = 1000.0F / MS; }
} else if (sw.contains("fps")) {
fps = sw["fps"].get_value<float>();
}
if (sw.contains("loop")) { loop = sw["loop"].get_value<bool>(); }
if (sw.contains("active_frames")) {
for (const auto& f : sw["active_frames"]) {
// cppcheck-suppress useStlAlgorithm
active_out.push_back(f.get_value<int>());
}
}
}
return {frames, fps, loop};
}
} // namespace
WhipAttack::WhipAttack(const fkyaml::node& yaml) {
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("whip_attack");
collides_ = false;
interactable_ = false;
if (yaml.contains("sprites")) {
const auto& s = yaml["sprites"];
if (s.contains("north")) { sprite_north_ = readDirSprite(s["north"]); }
if (s.contains("south")) { sprite_south_ = readDirSprite(s["south"]); }
if (s.contains("east")) { sprite_east_ = readDirSprite(s["east"]); }
if (s.contains("west")) { sprite_west_ = readDirSprite(s["west"]); }
}
anim_swing_ = buildSwingAnim(yaml, active_frames_);
anim_ = anim_swing_;
}
void WhipAttack::configure(Direction dir, const SDL_FRect& owner_box, float range_px) {
direction_ = dir;
range_px_ = range_px;
// Draw rect (= pixel_rect_): cobreix tot el sprite del látigo segons
// la taula d'offsets del disseny:
// E: (player.x - 16, player.y ), 80×32
// W: (player.x - 48, player.y ), 80×32
// N: (player.x, player.y - 48 ), 16×96
// S: (player.x, player.y - 16 ), 16×96
const auto& chosen = spriteFor(dir);
SDL_FRect draw{0.0F, 0.0F, chosen.frame_w, chosen.frame_h};
switch (dir) {
case Direction::EAST:
draw.x = owner_box.x - 16.0F;
draw.y = owner_box.y;
break;
case Direction::WEST:
draw.x = owner_box.x - 48.0F;
draw.y = owner_box.y;
break;
case Direction::NORTH:
draw.x = owner_box.x;
draw.y = owner_box.y - 48.0F;
break;
case Direction::SOUTH:
draw.x = owner_box.x;
draw.y = owner_box.y - 16.0F;
break;
}
setPixelRect(draw);
// Damage rect: 48×16 (E/W) o 16×48 (N/S) al nivell dels peus del
// jugador (els últims 16 px del seu pixel_rect — convenció estàndard
// del player AEE), pegat al borde en la direcció d'atac.
// computeDamageRect rep la cantonada del rect MÀXIM (sense retallar)
// i recorre internament la línia per topar amb el primer bloquejador.
const float COLLISION_TOP_Y = owner_box.y + owner_box.h - 16.0F;
const float OWNER_RIGHT = owner_box.x + owner_box.w;
const float OWNER_BOTTOM = owner_box.y + owner_box.h;
float spawn_x = 0.0F;
float spawn_y = 0.0F;
switch (dir) {
case Direction::EAST:
spawn_x = OWNER_RIGHT;
spawn_y = COLLISION_TOP_Y;
break;
case Direction::WEST:
spawn_x = owner_box.x - range_px;
spawn_y = COLLISION_TOP_Y;
break;
case Direction::NORTH:
spawn_x = owner_box.x;
spawn_y = COLLISION_TOP_Y - range_px;
break;
case Direction::SOUTH:
spawn_x = owner_box.x;
spawn_y = OWNER_BOTTOM;
break;
}
computeDamageRect(dir, spawn_x, spawn_y, range_px);
// Snapshot del feet_y del jugador. La convenció estàndard del Player
// AEE té el seu feet_y a la base inferior del pixel_rect (collision
// 16×16 als últims 16 px del sprite 16×32), així que owner_box.y +
// owner_box.h reprodueix `Player::getFeetY()` exactament.
owner_feet_y_ = owner_box.y + owner_box.h;
if (anim_.has_value()) { anim_->reset(); }
}
auto WhipAttack::getFeetY() const -> float {
return owner_feet_y_;
}
void WhipAttack::setWorldContext(EntityManager* manager, const std::vector<Player*>* players) {
entity_manager_ = manager;
players_ = players;
}
void WhipAttack::computeDamageRect(Direction dir, float spawn_x, float spawn_y, float range_px) {
// Caminem píxel a píxel des de la vora del jugador en la direcció
// d'atac, fins a topar amb un mur o sarc, o esgotar `range_px`.
// L'amplada perpendicular és `tile_size` (els passadissos del joc
// tenen exactament aquesta mida i l'atac és lateral).
const auto TILE = static_cast<float>(Map::tile_size);
const int MAX_PX = static_cast<int>(std::max(0.0F, range_px));
const auto MAX = static_cast<float>(MAX_PX);
float length = 0.0F;
auto try_probe = [&](float l) -> bool {
SDL_FRect probe{0.0F, 0.0F, 0.0F, 0.0F};
switch (dir) {
case Direction::EAST:
probe = {.x = spawn_x, .y = spawn_y, .w = l, .h = TILE};
break;
case Direction::WEST:
// Per a W, la cara "del jugador" és la dreta; com creix
// `l`, la vora esquerra retrocedeix. spawn_x apunta al
// límit esquerre amb l = MAX (cas no retallat).
probe = {.x = spawn_x + (MAX - l), .y = spawn_y, .w = l, .h = TILE};
break;
case Direction::SOUTH:
probe = {.x = spawn_x, .y = spawn_y, .w = TILE, .h = l};
break;
case Direction::NORTH:
probe = {.x = spawn_x, .y = spawn_y + (MAX - l), .w = TILE, .h = l};
break;
}
return Map::insidePlayfield(probe) && !Map::collides(probe);
};
for (int i = 1; i <= MAX_PX; ++i) {
const auto L = static_cast<float>(i);
if (try_probe(L)) {
length = L;
} else {
break;
}
}
switch (dir) {
case Direction::EAST:
damage_rect_ = {.x = spawn_x, .y = spawn_y, .w = length, .h = TILE};
return;
case Direction::WEST:
damage_rect_ = {.x = spawn_x + (MAX - length), .y = spawn_y, .w = length, .h = TILE};
return;
case Direction::SOUTH:
damage_rect_ = {.x = spawn_x, .y = spawn_y, .w = TILE, .h = length};
return;
case Direction::NORTH:
damage_rect_ = {.x = spawn_x, .y = spawn_y + (MAX - length), .w = TILE, .h = length};
return;
}
}
auto WhipAttack::getSurfaceKey() const -> const std::string& {
return spriteFor(direction_).surface_key;
}
auto WhipAttack::spriteFor(Direction dir) const -> const DirSprite& {
switch (dir) {
case Direction::NORTH:
return sprite_north_;
case Direction::SOUTH:
return sprite_south_;
case Direction::EAST:
return sprite_east_;
case Direction::WEST:
return sprite_west_;
}
return sprite_east_;
}
void WhipAttack::update(float delta_time) {
Entity::update(delta_time);
if (isFrameActive()) { applyDamage(); }
if (anim_.has_value() && anim_->completed()) { markDead(); }
}
namespace {
auto whipDirToMummyDir(WhipAttack::Direction d) -> Mummy::Direction {
switch (d) {
case WhipAttack::Direction::NORTH:
return Mummy::Direction::UP;
case WhipAttack::Direction::SOUTH:
return Mummy::Direction::DOWN;
case WhipAttack::Direction::EAST:
return Mummy::Direction::RIGHT;
case WhipAttack::Direction::WEST:
return Mummy::Direction::LEFT;
}
return Mummy::Direction::NONE;
}
auto whipDirToPlayerCardinal(WhipAttack::Direction d) -> Player::Cardinal {
switch (d) {
case WhipAttack::Direction::NORTH:
return Player::Cardinal::NORTH;
case WhipAttack::Direction::SOUTH:
return Player::Cardinal::SOUTH;
case WhipAttack::Direction::EAST:
return Player::Cardinal::EAST;
case WhipAttack::Direction::WEST:
return Player::Cardinal::WEST;
}
return Player::Cardinal::NONE;
}
auto playerIsTargetable(const Player* p) -> bool {
if (p == nullptr) { return false; }
const auto STATE = p->getState();
return STATE != Player::State::HURT && STATE != Player::State::EXITED && STATE != Player::State::DISABLED;
}
// Tracta una mòmia candidata: si és whippable i la seua caja solapa
// amb `damage_rect`, la posa a HURT i la marca al set. Extret per
// acotar la complexitat cognitiva d'`applyDamage` (clang-tidy llindar).
void hitMummyIfApplicable(
Entity* e,
const SDL_FRect& damage_rect,
WhipAttack::Direction dir,
std::unordered_set<void*>& already_hit) {
if (e == nullptr || e->isDead()) { return; }
if (already_hit.contains(static_cast<void*>(e))) { return; }
auto* mummy = dynamic_cast<Mummy*>(e);
if (mummy == nullptr || !mummy->isWhippable()) { return; }
const auto BOX = mummy->getCollisionRect();
if (!SDL_HasRectIntersectionFloat(&damage_rect, &BOX)) { return; }
mummy->enterHurt(whipDirToMummyDir(dir));
already_hit.insert(static_cast<void*>(e));
}
void hitPlayerIfApplicable(
Player* p,
const Player* owner,
const SDL_FRect& damage_rect,
WhipAttack::Direction dir,
std::unordered_set<void*>& already_hit) {
if (p == owner || !playerIsTargetable(p)) { return; }
if (already_hit.contains(static_cast<void*>(p))) { return; }
const auto BOX = p->getBaseBox();
if (!SDL_HasRectIntersectionFloat(&damage_rect, &BOX)) { return; }
p->enterHurt(whipDirToPlayerCardinal(dir));
already_hit.insert(static_cast<void*>(p));
}
} // namespace
void WhipAttack::applyDamage() {
if (entity_manager_ != nullptr) {
for (auto* e : entity_manager_->getEntities()) {
hitMummyIfApplicable(e, damage_rect_, direction_, already_hit_);
}
}
if (players_ != nullptr) {
for (auto* p : *players_) {
hitPlayerIfApplicable(p, owner_, damage_rect_, direction_, already_hit_);
}
}
}
auto WhipAttack::isFrameActive() const -> bool {
if (!anim_.has_value()) { return false; }
const int FRAME = anim_->currentFrame();
return std::ranges::any_of(active_frames_, [FRAME](int f) { return f == FRAME; });
}
REGISTER_ENTITY("whip_attack", WhipAttack)
@@ -0,0 +1,162 @@
#pragma once
#include <SDL3/SDL.h>
#include <cstdint>
#include <string>
#include <unordered_set>
#include <vector>
#include "external/fkyaml_node.hpp"
#include "game/entities/animation_component.hpp"
#include "game/entities/attack.hpp"
class Entity;
class EntityManager;
class Player;
// Atac de látigo: spawnejat pel jugador en entrar a `ATTACKING`. Viu la
// durada de la seua animació (`swing`) i s'autoelimina al final.
//
// Sprites: 4 fitxers GIF distints, un per cardinal (sense flips). La
// mida del frame canvia entre direccions (E/W: 80×32, N/S: 16×96),
// cosa que el sistema d'animació actual no permet expressar amb una
// sola surface. Per això el YAML llista les 4 entrades sota `sprites:`
// i `configure()` tria la que toca segons direcció.
//
// Dos rectangles distints, intencionadament diferenciats:
// - `pixel_rect_` (heretat d'`Entity`) = `draw_rect`. Cobreix tot el
// sprite del látigo: 16 px de wind-up darrere del jugador, 16 px
// de solapament amb el jugador, i 48 px d'atac frontal (per E/W;
// proporcions equivalents per N/S amb 32 px de solapament). NO es
// retalla per bloquejadors — només render visual.
// - `damage_rect_` = caixa de dany al nivell dels peus del jugador
// (centrada sobre la base 16×16). 48×16 (E/W) o 16×48 (N/S),
// pegada al borde del jugador en la direcció d'atac. SÍ es retalla
// pel primer bloquejador (mur extern del playfield o sarcòfag).
//
// Z-order: `getFeetY()` delega al `owner_` per garantir que el látigo
// quede al mateix bucket d'y-sort que el jugador que l'ha llançat. Si
// no, l'altura física del sprite (esp. en N/S, que té 96 px) crearia
// solapaments raros amb mòmies a la trajectòria.
class WhipAttack : public Attack {
public:
// Direcció cardinal del swing. Coincideix valors amb
// `Player::Cardinal` (1=N, 2=S, 3=E, 4=W) per facilitar la
// conversió a la frontera de l'event `whip_spawn`.
enum class Direction : std::uint8_t {
NORTH = 1,
SOUTH = 2,
EAST = 3,
WEST = 4,
};
// Definició estàtica d'una direcció: clau al `Resource::Cache` i mida
// del frame en píxels. Llegida del YAML una vegada al ctor; usada per
// configure() per omplir surface_ i pixel_rect_. Pública perquè el
// helper d'anàlisi del YAML viu en un namespace anònim al .cpp.
struct DirSprite {
std::string surface_key;
float frame_w{0.0F};
float frame_h{0.0F};
};
explicit WhipAttack(const fkyaml::node& yaml);
// Configura l'estat runtime de la instància. Cal cridar-lo abans del
// primer `update`. A partir del `owner_box` (pixel_rect del jugador,
// típicament 16×32) i la direcció d'atac, deriva:
// - `pixel_rect_` (= draw_rect): cantonada i mida del sprite del
// látigo segons la taula d'offsets (E: -16, 0, 80×32; etc.).
// - `damage_rect_`: caixa de dany 48×16 (o 16×48) pegada al
// borde del jugador, retallada contra murs i sarcs.
// Convenció implícita: el jugador té collision rect 16×16 als
// últims 16 px del seu pixel_rect (els "peus"); aquesta funció
// n'usa la geometria per al damage_rect i el feet_y delegat.
void configure(Direction dir, const SDL_FRect& owner_box, float range_px);
// Injecta el context que necessita la fase de dany: punter a
// l'`EntityManager` (per iterar Hazards) i a la llista de jugadors
// (per al friendly fire). Si no es crida, els frames actius no
// apliquen dany — el whip queda merament visual. Cridar abans del
// primer `update` (típicament just després de `configure`).
void setWorldContext(EntityManager* manager, const std::vector<Player*>* players);
// Avança animació; durant frames actius, aplica dany; quan
// l'animació es completa, marca dead.
void update(float delta_time) override;
[[nodiscard]] auto getDirection() const -> Direction { return direction_; }
// True si el frame actual de l'animació és un dels `active_frames`
// declarats al YAML (els frames durant els quals la hitbox aplica
// dany). Wind-up i recovery retornen false.
[[nodiscard]] auto isFrameActive() const -> bool;
// Llistat dels frames actius (lectura del YAML, immutable per
// instància). Exposat per als smoke tests.
[[nodiscard]] auto getActiveFrames() const -> const std::vector<int>& { return active_frames_; }
// Llargada de la hitbox abans de retallar contra murs/sarcs (en píxels).
[[nodiscard]] auto getRangePx() const -> float { return range_px_; }
// Hitbox efectiva de dany (ja retallada pel primer bloquejador en la
// línia d'atac). Exposada per als smoke tests; en producció només la
// consumeix la pròpia `applyDamage`.
[[nodiscard]] auto getDamageRect() const -> SDL_FRect { return damage_rect_; }
// Y dels peus delegada al `owner_box` capturat a `configure()`: el
// látigo es z-ordena al mateix bucket que el jugador que l'ha
// llançat (= base inferior del seu pixel_rect, que coincideix amb
// el feet_y del jugador a la convenció estàndard del player AEE).
// Snapshot, no consulta dinàmica: el jugador queda quiet durant
// ATTACKING, així que snapshot ≡ live per a la vida del whip.
[[nodiscard]] auto getFeetY() const -> float override;
// Clau al `Resource::Cache` per a la surface de la direcció actual
// (la que ha triat `configure()`). El caller (típicament `GameScene`)
// resol la surface i la passa via `setSurface()`. Aquesta separació
// manté el codi de l'entitat sense dependència directa al
// `Resource::Cache`, igual que la resta d'entitats del joc.
[[nodiscard]] auto getSurfaceKey() const -> const std::string&;
private:
[[nodiscard]] auto spriteFor(Direction dir) const -> const DirSprite&;
// Calcula la `damage_rect_` final: retalla la longitud de l'atac
// contra el primer bloquejador (mur extern del playfield o sarc) en
// la línia d'atac. Implementació iterativa píxel a píxel — el rang
// típic és ~48 px, així que el cost és menyspreable.
void computeDamageRect(Direction dir, float spawn_x, float spawn_y, float range_px);
// Aplica dany aquest frame: itera Hazards (Mummy whippable) i
// jugadors (excepte `owner_`) que solapen amb `damage_rect_` i
// estan en estat "atacable", crida `enterHurt` amb la direcció
// adequada i els marca a `already_hit_` per garantir un sol
// impacte per swing per objectiu.
void applyDamage();
Direction direction_{Direction::EAST};
AnimationComponent anim_swing_;
std::vector<int> active_frames_;
// Set d'objectius ja colpejats aquest swing: usem `void*` perquè
// hi guardem tant `Entity*` (Mummy) com `Player*` (que NO hereta
// d'Entity) sense `reinterpret_cast` semànticament dubtós.
std::unordered_set<void*> already_hit_;
float range_px_{0.0F};
SDL_FRect damage_rect_{0.0F, 0.0F, 0.0F, 0.0F};
// Snapshot del feet_y de l'owner pres a `configure()` (= base
// inferior del seu pixel_rect, que coincideix amb el `getFeetY()`
// del Player en la convenció estàndard d'AEE). El retornem des
// del nostre `getFeetY()` per delegar el bucket d'y-sort al jugador.
float owner_feet_y_{0.0F};
EntityManager* entity_manager_{nullptr};
const std::vector<Player*>* players_{nullptr};
DirSprite sprite_north_;
DirSprite sprite_south_;
DirSprite sprite_east_;
DirSprite sprite_west_;
};
+4 -2
View File
@@ -140,8 +140,10 @@ class Entity {
// Y dels peus: la vora inferior de la caixa de col·lisió. La usarà el
// Z-order per ordenar entitats i jugador de menys a més profunditat
// (REND-01, quan s'implemente).
[[nodiscard]] auto getFeetY() const -> float;
// (REND-01, quan s'implemente). Virtual perquè entitats efímeres
// (com `WhipAttack`) poden delegar al seu owner per quedar al
// mateix bucket d'y-sort que el jugador que les ha llançat.
[[nodiscard]] virtual auto getFeetY() const -> float;
// El loader li assigna una caixa explícita llegida del YAML; sense
// aquesta crida, l'entitat queda sense `CollisionBox` i la caixa de
+29 -1
View File
@@ -1,5 +1,7 @@
#pragma once
#include <SDL3/SDL.h>
#include <cstddef>
#include <cstdint>
#include <functional>
@@ -12,6 +14,7 @@
// Forward: els events poden dur un punter a `Entity` (p. ex. quin sarcòfag
// s'ha obert). No cal incloure el header complet aquí.
class Entity;
class Player;
// Event pub/sub minimalista. Hi ha un únic `EventBus` per mundus (propietat
// de l'`EntityManager`); les entitats que el necessiten en reben un punter
@@ -49,9 +52,31 @@ struct WorldPos {
float y{0.0F};
};
// Payload de `whip_spawn`: el `Player` el publica quan entra a ATTACKING
// i el `GameScene` el consumeix per crear un `WhipAttack` via
// `EntityManager::add`. Així el Player no depèn ni de `EntityManager` ni de
// `WhipAttack`, i el contracte queda dins del bus.
// - `owner` : Player que ha llançat l'atac (no rep dany propi).
// - `direction` : Player::Cardinal castat a int (1=N, 2=S, 3=E, 4=W) per
// evitar dependència cíclica al header.
// - `owner_box` : pixel_rect del jugador (típicament 16×32 a AEE). El
// WhipAttack en deriva tant la `draw_rect_` (sprite del
// látigo, més gran que el jugador per cobrir wind-up i
// solapament) com la `damage_rect_` (caixa de dany al
// nivell dels peus, retallada per murs/sarcs).
// - `range_px` : longitud nominal de la hitbox de dany abans de
// retallar contra murs i sarcòfags. Llegida del YAML
// del jugador.
struct WhipSpawnPayload {
Player* owner{nullptr};
int direction{0};
SDL_FRect owner_box{0.0F, 0.0F, 0.0F, 0.0F};
float range_px{0.0F};
};
struct Event {
std::string name;
using Payload = std::variant<std::monostate, int, std::string, Entity*, TilePos, PaintedTile, WorldPos>;
using Payload = std::variant<std::monostate, int, std::string, Entity*, TilePos, PaintedTile, WorldPos, WhipSpawnPayload>;
Payload payload;
// Jugador que ha originat (o provocat) l'event. -1 = sense jugador
// associat (events del món, com `sarcophagus_opened` des de la pròpia
@@ -101,6 +126,9 @@ class EventBus {
void publish(const std::string& name, WorldPos pos) {
publish(Event{.name = name, .payload = pos});
}
void publish(const std::string& name, const WhipSpawnPayload& whip) {
publish(Event{.name = name, .payload = whip});
}
private:
struct Subscription {
+170
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,81 @@ 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);
if (facing == Cardinal::NONE) { return; }
// 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),
@@ -788,6 +918,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 +1128,37 @@ 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 canviem a `recover_*` per a frames
// pròpis de recuperació. Mentre l'art no estiga dibuixat, els
// YAMLs apunten `recover_*` als mateixos frames que `stand_*` —
// visualment és idèntic, però la separació deixa l'arquitectura
// preparada perquè en afegir frames reals només calga tocar el
// YAML, no el codi.
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 "recover_up";
case Facing::DOWN:
return "recover_down";
case Facing::SIDE:
break;
}
return "recover";
}
switch (facing_) {
case Facing::UP:
return moving ? "walk_up" : "stand_up";
+63
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
@@ -431,12 +441,16 @@ class Player : public AnimatedSprite {
// Cardinal complet (N/S/E/W) usat per a la detecció de girs de 180°.
// El parell (Facing, flip) només té 3+1 estats; aquesta enum n'afegeix
// la 4a sense reorganitzar el sistema d'animacions.
// Pública perquè el `WhipAttack` la passa a `enterHurt` quan colpeja
// un altre jugador (friendly fire).
public:
enum class Cardinal : std::uint8_t { NONE,
NORTH,
SOUTH,
EAST,
WEST };
private:
// Configuració d'inèrcia llegida del bloc `inertia:` del YAML. Amb
// `brake_time_ms == 0` (o bloc absent) la inèrcia està desactivada i
// el comportament és instantani: soltar input para el jugador en sec.
@@ -503,6 +517,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í:
@@ -513,8 +558,12 @@ class Player : public AnimatedSprite {
// i tria el que dóna més recorregut lliure.
// Decelera linealment fins a parar i, en eixe instant, torna a
// GROUNDED. Sense col·lisió amb altres jugadors mentre dura.
// Pública: la dispara tant la cinemàtica de "caure damunt" com el
// `WhipAttack` quan colpeja un altre jugador (friendly fire).
public:
void enterHurt(Cardinal preferred = Cardinal::NONE);
private:
// Tria la direcció definitiva del HURT. Si `preferred` cabe el
// recorregut natural complet, la retorna; d'altra manera tria la
// direcció amb més recorregut lliure entre els 4 cardinals.
@@ -549,10 +598,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();
+36
View File
@@ -28,7 +28,9 @@
#include "core/ui/ui_config.hpp"
#include "external/fkyaml_node.hpp"
#include "game/entities/concrete/dust.hpp"
#include "game/entities/concrete/whip_attack.hpp"
#include "game/entities/entity.hpp"
#include "game/entities/entity_factory.hpp"
#include "game/entities/entity_loader.hpp"
#include "game/entities/event_bus.hpp"
#include "game/entities/player.hpp"
@@ -312,6 +314,18 @@ GameScene::GameScene(Config config)
if (pos != nullptr) { spawnDust(pos->x, pos->y, true); }
});
// Whip: precarreguem les 4 surfaces (una per cardinal) abans del
// primer spawn perquè el Player no facte disc al primer atac.
// Subscriu `whip_spawn` per crear el `WhipAttack` corresponent.
Resource::Cache::get().loadSurface("whip_east.gif", "data/entities/whip_attack/whip_east.gif");
Resource::Cache::get().loadSurface("whip_west.gif", "data/entities/whip_attack/whip_west.gif");
Resource::Cache::get().loadSurface("whip_north.gif", "data/entities/whip_attack/whip_north.gif");
Resource::Cache::get().loadSurface("whip_south.gif", "data/entities/whip_attack/whip_south.gif");
entity_manager_.getBus().subscribe("whip_spawn", [this](const Event& ev) {
const auto* p = std::get_if<WhipSpawnPayload>(&ev.payload);
if (p != nullptr) { spawnWhip(*p); }
});
// Controls per jugador: P1 amb fletxes, P2 amb WASD. Cada Player
// escolta les seues pròpies tecles dins del seu `update`, no hi ha
// estat global d'input.
@@ -321,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,
@@ -328,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
@@ -877,6 +893,26 @@ void GameScene::spawnDust(float feet_x_center, float feet_y, bool is_landing) {
entity_manager_.add(std::move(dust));
}
void GameScene::spawnWhip(const WhipSpawnPayload& payload) {
// El YAML ja viu cacheat al `EntityLoader`; les 4 surfaces les hem
// precarregat al ctor. `configure()` selecciona la mida segons
// direcció i posa pixel_rect; aquí resolem la surface via
// `Resource::Cache` (mateix patró que `spawnDust`) per evitar que
// l'entitat depengui directament del cache.
auto whip_yaml = EntityLoader::loadTemplate("whip_attack");
auto whip = std::make_unique<WhipAttack>(whip_yaml);
whip->setOwner(payload.owner);
whip->configure(
static_cast<WhipAttack::Direction>(payload.direction),
payload.owner_box,
payload.range_px);
whip->setSurface(Resource::Cache::get().getSurface(whip->getSurfaceKey()));
// Donem accés a Hazards i jugadors per a la fase de dany. Sense això
// el whip queda merament visual.
whip->setWorldContext(&entity_manager_, &players_view_);
entity_manager_.add(std::move(whip));
}
void GameScene::handlePauseInput() {
#ifdef _DEBUG
// Mutex amb l'editor: si està actiu, ignorem la tecla de pausa.
+6
View File
@@ -75,6 +75,12 @@ class GameScene : public Scene {
// `is_landing` selecciona la variant de l'animació al `Dust`.
void spawnDust(float feet_x_center, float feet_y, bool is_landing);
// Spawna un `WhipAttack` a partir del payload publicat al bus quan
// un Player entra a `ATTACKING`. Llig la plantilla cacheada del YAML
// i delega la mida + surface al `WhipAttack::configure()` segons
// direcció.
void spawnWhip(const struct WhipSpawnPayload& payload);
Config config_;
Tilemap tilemap_;
+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 {
+74
View File
@@ -431,6 +431,77 @@ namespace {
REQUIRE(mummy.getDirX() <= 0.0F);
}
void testMummyHurtState() {
std::cout << "[T15] mummy enterHurt → fugida + recuperació\n";
Map::loadFrom("data/maps/pyramid.yaml");
fkyaml::node tmpl = EntityLoader::loadTemplate("mummy");
Mummy mummy(tmpl);
mummy.setCollisionBox({.offset_x = 0, .offset_y = 12, .width = 12, .height = 12});
// Mateixa col·locació que testMummyStep: corredor exterior
// superior, lliure de sarcs, prou marge per fugir cap a la dreta.
mummy.setPixelRect({.x = 60.0F, .y = 40.0F, .w = 12.0F, .h = 24.0F});
// Direcció preferida: dreta. Mòmia ha d'entrar en HURT i avançar
// a la dreta (la velocitat de hurt és més alta que la base).
mummy.enterHurt(Mummy::Direction::RIGHT);
REQUIRE(mummy.getState() == Mummy::State::HURT);
REQUIRE(mummy.getDirX() > 0.0F);
const auto BEFORE = mummy.getPixelRect();
mummy.update(0.05F); // 50 ms: hauria d'avançar
const auto AFTER = mummy.getPixelRect();
REQUIRE(AFTER.x > BEFORE.x);
REQUIRE(mummy.getState() == Mummy::State::HURT);
// Avancem fins esgotar el timer (`hurt.duration_ms` = 600 ms al
// YAML → 0.6 s; sumem 1 s amb un sol step gran per garantir-ho).
mummy.update(1.0F);
REQUIRE(mummy.getState() == Mummy::State::ROAMING);
}
void testMummyHurtAgainstWall() {
std::cout << "[T16] mummy HURT contra mur → surt abans del timer\n";
Map::loadFrom("data/maps/pyramid.yaml");
fkyaml::node tmpl = EntityLoader::loadTemplate("mummy");
Mummy mummy(tmpl);
mummy.setCollisionBox({.offset_x = 0, .offset_y = 12, .width = 12, .height = 12});
// Posicionem la mòmia tocant la paret dreta del playfield i la
// fem fugir cap a la dreta: el primer pas hauria de topar amb
// la paret i fer-la sortir de HURT.
const float RIGHT_WALL = static_cast<float>(Map::playfield_x + Map::playfield_w);
mummy.setPixelRect({.x = RIGHT_WALL - 12.0F, .y = 40.0F, .w = 12.0F, .h = 24.0F});
// La direcció preferida (RIGHT) està bloquejada al test
// d'`is_open`; entrarem amb una perpendicular o quieta.
// L'objecte de la prova és que `state_` torne a ROAMING quan
// el moviment topa: ho forcem fent que `enterHurt` triï igual
// RIGHT i topem en cridar `update`. Per aconseguir-ho posicionem
// la mòmia al límit i forcem la direcció via `setDirection`
// ABANS d'`enterHurt`. Però com `enterHurt` reescriu la
// direcció, el camí més directe és:
// 1) `enterHurt(RIGHT)` → si is_open(RIGHT) val false, intenta
// perpendiculars; si totes fallen, queda quieta.
// 2) Si està quieta, el HURT tampoc avança i només mor pel
// timer. Aquesta variant del test NO comprova la sortida
// per col·lisió; només verifica que entrar a HURT a la
// paret no peta.
mummy.enterHurt(Mummy::Direction::RIGHT);
REQUIRE(mummy.getState() == Mummy::State::HURT);
// Update llarg → expira el timer i torna a ROAMING.
mummy.update(1.0F);
REQUIRE(mummy.getState() == Mummy::State::ROAMING);
}
void testMummyWhippableFlag() {
std::cout << "[T17] mummy YAML llig `whippable` correctament\n";
fkyaml::node tmpl = EntityLoader::loadTemplate("mummy");
Mummy mummy(tmpl);
REQUIRE(mummy.isWhippable());
}
void testTorchAnimation() {
std::cout << "[T10] torch animation wired from yaml\n";
// Regressió: el torch.yaml declarava `frames: [0]` i el render no
@@ -510,6 +581,9 @@ auto main() -> int {
testSarcophagusYaml();
testCollisionBox();
testMummyStep();
testMummyHurtState();
testMummyHurtAgainstWall();
testMummyWhippableFlag();
testTorchAnimation();
testExitedAggregator();
+302
View File
@@ -0,0 +1,302 @@
// Smoke tests del WhipAttack: hitbox per direcció, retall contra murs i
// sarcs, aplicació de dany a mòmies whippable, alreadyHit. Friendly
// fire (Player↔Player) NO es cobreix aquí — Player viu enllaçat amb
// stubs sense estat real (veure whip_attack_player_stubs.cpp).
//
// Convencions iguals a test_entities_smoke: framework-less, REQUIRE
// macro, comptador `g_failures`. Execució: `./test_whip_attack` des de
// build/, working_dir = arrel del repo.
#include <SDL3/SDL.h>
#include <cstdlib>
#include <iostream>
#include <memory>
#include <vector>
#include "game/entities/concrete/mummy.hpp"
#include "game/entities/concrete/whip_attack.hpp"
#include "game/entities/entity_loader.hpp"
#include "game/entities/entity_manager.hpp"
#include "game/map/map.hpp"
namespace {
int g_failures = 0;
void check(bool cond, const char* msg, const char* file, int line) {
if (!cond) {
std::cerr << " FAIL: " << msg << " (" << file << ":" << line << ")\n";
++g_failures;
}
}
#define REQUIRE(cond) check((cond), #cond, __FILE__, __LINE__)
// Caja de jugador "estàndard" AEE: 16×32 amb cantonada (x, y).
// El damage_rect del whip s'alinearà al feet box (els últims 16 px),
// i el draw_rect respectarà els offsets de la taula del disseny.
auto playerBox(float x, float y) -> SDL_FRect {
return SDL_FRect{.x = x, .y = y, .w = 16.0F, .h = 32.0F};
}
// Spawneja un WhipAttack ben configurat per al test, sense surface.
// Els tests no exerciten render, així que `surface_=nullptr` és OK.
auto makeWhip(WhipAttack::Direction dir, const SDL_FRect& owner_box, float range_px)
-> std::unique_ptr<WhipAttack> {
auto yaml = EntityLoader::loadTemplate("whip_attack");
auto whip = std::make_unique<WhipAttack>(yaml);
whip->configure(dir, owner_box, range_px);
return whip;
}
void testWhipDrawRectPerDirection() {
std::cout << "[W1] WhipAttack draw_rect per direcció\n";
Map::loadFrom("data/maps/pyramid.yaml");
// Owner ben dins del playfield perquè el draw_rect (no es retalla)
// tinga la mida nominal completa.
// Posicionem el jugador amb la collision (bottom 16×16) al
// corredor entre files 2 i 3 de sarcs (y∈[144,160]) i a la
// columna entre cols 0 i 1 (x∈[88,104]) — així el damage_rect
// cap al sarc-buit corredor i no es retalla per sarcs.
const float OX = static_cast<float>(Map::playfield_x + 64);
const float OY = static_cast<float>(Map::playfield_y + 80);
const auto BOX = playerBox(OX, OY);
const float RANGE = 48.0F;
const auto WE = makeWhip(WhipAttack::Direction::EAST, BOX, RANGE);
REQUIRE(WE->getPixelRect().w == 80.0F);
REQUIRE(WE->getPixelRect().h == 32.0F);
const auto WW = makeWhip(WhipAttack::Direction::WEST, BOX, RANGE);
REQUIRE(WW->getPixelRect().w == 80.0F);
REQUIRE(WW->getPixelRect().h == 32.0F);
const auto WN = makeWhip(WhipAttack::Direction::NORTH, BOX, RANGE);
REQUIRE(WN->getPixelRect().w == 16.0F);
REQUIRE(WN->getPixelRect().h == 96.0F);
const auto WS = makeWhip(WhipAttack::Direction::SOUTH, BOX, RANGE);
REQUIRE(WS->getPixelRect().w == 16.0F);
REQUIRE(WS->getPixelRect().h == 96.0F);
}
void testWhipDrawRectOffsets() {
std::cout << "[W7] offsets del draw_rect per direcció (taula del disseny)\n";
Map::loadFrom("data/maps/pyramid.yaml");
// Posicionem el jugador amb la collision (bottom 16×16) al
// corredor entre files 2 i 3 de sarcs (y∈[144,160]) i a la
// columna entre cols 0 i 1 (x∈[88,104]) — així el damage_rect
// cap al sarc-buit corredor i no es retalla per sarcs.
const float OX = static_cast<float>(Map::playfield_x + 64);
const float OY = static_cast<float>(Map::playfield_y + 80);
const auto BOX = playerBox(OX, OY);
const float RANGE = 48.0F;
// E: (player.x - 16, player.y), 80×32.
const auto WE = makeWhip(WhipAttack::Direction::EAST, BOX, RANGE);
REQUIRE(WE->getPixelRect().x == OX - 16.0F);
REQUIRE(WE->getPixelRect().y == OY);
// W: (player.x - 48, player.y), 80×32.
const auto WW = makeWhip(WhipAttack::Direction::WEST, BOX, RANGE);
REQUIRE(WW->getPixelRect().x == OX - 48.0F);
REQUIRE(WW->getPixelRect().y == OY);
// N: (player.x, player.y - 48), 16×96.
const auto WN = makeWhip(WhipAttack::Direction::NORTH, BOX, RANGE);
REQUIRE(WN->getPixelRect().x == OX);
REQUIRE(WN->getPixelRect().y == OY - 48.0F);
// S: (player.x, player.y - 16), 16×96.
const auto WS = makeWhip(WhipAttack::Direction::SOUTH, BOX, RANGE);
REQUIRE(WS->getPixelRect().x == OX);
REQUIRE(WS->getPixelRect().y == OY - 16.0F);
}
void testWhipFeetYDelegation() {
std::cout << "[W8] feet_y delegat a la base de l'owner_box\n";
Map::loadFrom("data/maps/pyramid.yaml");
// Posicionem el jugador amb la collision (bottom 16×16) al
// corredor entre files 2 i 3 de sarcs (y∈[144,160]) i a la
// columna entre cols 0 i 1 (x∈[88,104]) — així el damage_rect
// cap al sarc-buit corredor i no es retalla per sarcs.
const float OX = static_cast<float>(Map::playfield_x + 64);
const float OY = static_cast<float>(Map::playfield_y + 80);
const auto BOX = playerBox(OX, OY);
const float EXPECTED_FEET_Y = OY + 32.0F; // base inferior del 16×32
// Independent de la direcció: el feet_y és el del jugador.
for (auto dir : {WhipAttack::Direction::EAST,
WhipAttack::Direction::WEST,
WhipAttack::Direction::NORTH,
WhipAttack::Direction::SOUTH}) {
const auto W = makeWhip(dir, BOX, 48.0F);
REQUIRE(W->getFeetY() == EXPECTED_FEET_Y);
}
}
void testWhipHitboxClippedByWall() {
std::cout << "[W2] damage_rect retallat per la paret del playfield\n";
Map::loadFrom("data/maps/pyramid.yaml");
// Llancem un E des d'una posició a 10 px de la paret dreta del
// playfield, amb un range de 48 px → la hitbox efectiva ha de
// quedar acotada a < 48 i no més enllà del mur.
const float RIGHT_WALL = static_cast<float>(Map::playfield_x + Map::playfield_w);
const float OX = RIGHT_WALL - 16.0F - 10.0F; // l'owner mateix dins
const float OY = static_cast<float>(Map::playfield_y + 96);
const auto BOX = playerBox(OX, OY);
const float RANGE = 48.0F;
const auto W = makeWhip(WhipAttack::Direction::EAST, BOX, RANGE);
const auto DMG = W->getDamageRect();
REQUIRE(DMG.w < RANGE);
REQUIRE(DMG.x + DMG.w <= RIGHT_WALL);
}
void testWhipHitboxFreeAtFullRange() {
std::cout << "[W3] damage_rect sense bloquejador → longitud completa\n";
Map::loadFrom("data/maps/pyramid.yaml");
// Owner ben dins del playfield, range curt → 16 px lliures fins
// a topar amb res. La longitud efectiva = RANGE.
const float OX = static_cast<float>(Map::playfield_x + 32);
const float OY = static_cast<float>(Map::playfield_y + 96);
const auto BOX = playerBox(OX, OY);
const float RANGE = 16.0F;
const auto W = makeWhip(WhipAttack::Direction::SOUTH, BOX, RANGE);
const auto DMG = W->getDamageRect();
REQUIRE(DMG.h == RANGE);
REQUIRE(DMG.w == 16.0F);
}
void testWhipHitsMummyInRange() {
std::cout << "[W4] mòmia en rang entra a HURT\n";
Map::loadFrom("data/maps/pyramid.yaml");
EntityManager manager;
auto mummy_yaml = EntityLoader::loadTemplate("mummy");
auto mummy_owned = std::make_unique<Mummy>(mummy_yaml);
mummy_owned->setCollisionBox({.offset_x = 0, .offset_y = 12, .width = 12, .height = 12});
// Owner i mòmia al mateix corredor; mòmia 24 px a la dreta del
// jugador → dins del damage_rect (48 px de rang).
// Posicionem el jugador amb la collision (bottom 16×16) al
// corredor entre files 2 i 3 de sarcs (y∈[144,160]) i a la
// columna entre cols 0 i 1 (x∈[88,104]) — així el damage_rect
// cap al sarc-buit corredor i no es retalla per sarcs.
const float OX = static_cast<float>(Map::playfield_x + 64);
const float OY = static_cast<float>(Map::playfield_y + 80);
const auto BOX = playerBox(OX, OY);
// Posicionem la mòmia perquè la seua collision box solape amb
// la zona on cau el damage_rect (els últims 16 px verticals
// del player, és a dir y ∈ [OY+16, OY+32]).
// Volem que la collision (offset_y=12, height=12) solape amb el
// damage_rect del whip (a y ∈ [OY+16, OY+32]). Posant pixel_rect.y
// = OY, la collision queda a [OY+12, OY+24], 8 px d'overlap.
mummy_owned->setPixelRect({.x = OX + 24.0F, .y = OY, .w = 12.0F, .h = 24.0F});
auto* mummy_raw = static_cast<Mummy*>(manager.add(std::move(mummy_owned)));
auto whip = makeWhip(WhipAttack::Direction::EAST, BOX, 48.0F);
whip->setWorldContext(&manager, nullptr);
// 60 ms → frame 1 (active). active_frames: [1, 2].
whip->update(0.06F);
REQUIRE(mummy_raw->getState() == Mummy::State::HURT);
}
void testWhipNoHitOutOfRange() {
std::cout << "[W5] mòmia fora del rang no rep dany\n";
Map::loadFrom("data/maps/pyramid.yaml");
EntityManager manager;
auto mummy_yaml = EntityLoader::loadTemplate("mummy");
auto mummy_owned = std::make_unique<Mummy>(mummy_yaml);
mummy_owned->setCollisionBox({.offset_x = 0, .offset_y = 12, .width = 12, .height = 12});
// Mateixa col·locació corredor 2-3 que W4: el damage_rect cap
// íntegre al sarc-buit; només l'eix x distingirà "dins" de "fora".
const float OX = static_cast<float>(Map::playfield_x + 64);
const float OY = static_cast<float>(Map::playfield_y + 80);
const auto BOX = playerBox(OX, OY);
// Mòmia 96 px a la dreta de l'owner → fora d'un range de 32.
// Vertically aligned with the damage band perquè el test només
// depenga de l'eix horitzontal (no d'una falla d'alineament Y).
mummy_owned->setPixelRect({.x = OX + 96.0F, .y = OY, .w = 12.0F, .h = 24.0F});
auto* mummy_raw = static_cast<Mummy*>(manager.add(std::move(mummy_owned)));
auto whip = makeWhip(WhipAttack::Direction::EAST, BOX, 32.0F);
whip->setWorldContext(&manager, nullptr);
whip->update(0.06F);
REQUIRE(mummy_raw->getState() == Mummy::State::ROAMING);
}
void testWhipSingleHitPerSwing() {
std::cout << "[W6] una mòmia rep un sol impacte per swing\n";
Map::loadFrom("data/maps/pyramid.yaml");
EntityManager manager;
auto mummy_yaml = EntityLoader::loadTemplate("mummy");
auto mummy_owned = std::make_unique<Mummy>(mummy_yaml);
mummy_owned->setCollisionBox({.offset_x = 0, .offset_y = 12, .width = 12, .height = 12});
// Posicionem el jugador amb la collision (bottom 16×16) al
// corredor entre files 2 i 3 de sarcs (y∈[144,160]) i a la
// columna entre cols 0 i 1 (x∈[88,104]) — així el damage_rect
// cap al sarc-buit corredor i no es retalla per sarcs.
const float OX = static_cast<float>(Map::playfield_x + 64);
const float OY = static_cast<float>(Map::playfield_y + 80);
const auto BOX = playerBox(OX, OY);
// Volem que la collision (offset_y=12, height=12) solape amb el
// damage_rect del whip (a y ∈ [OY+16, OY+32]). Posant pixel_rect.y
// = OY, la collision queda a [OY+12, OY+24], 8 px d'overlap.
mummy_owned->setPixelRect({.x = OX + 24.0F, .y = OY, .w = 12.0F, .h = 24.0F});
auto* mummy_raw = static_cast<Mummy*>(manager.add(std::move(mummy_owned)));
auto whip = makeWhip(WhipAttack::Direction::EAST, BOX, 48.0F);
whip->setWorldContext(&manager, nullptr);
// Primera passada (frame 1, actiu) → mòmia a HURT.
whip->update(0.06F);
REQUIRE(mummy_raw->getState() == Mummy::State::HURT);
const float HURT_INITIAL = mummy_raw->getHurtRemainingS();
// Avancem la mòmia (descompta el seu timer) i tornem a tickar el
// whip mentre l'animació segueix activa: si `already_hit_`
// funciona, el segon hit es bloqueja i el timer de la mòmia NO
// es reseteja al valor inicial.
mummy_raw->update(0.05F);
const float HURT_AFTER_MUMMY_TICK = mummy_raw->getHurtRemainingS();
REQUIRE(HURT_AFTER_MUMMY_TICK < HURT_INITIAL);
whip->update(0.05F); // frame 2, encara actiu
REQUIRE(mummy_raw->getHurtRemainingS() == HURT_AFTER_MUMMY_TICK);
}
} // namespace
auto main() -> int {
std::cout << "== smoke tests: WhipAttack ==\n";
testWhipDrawRectPerDirection();
testWhipDrawRectOffsets();
testWhipFeetYDelegation();
testWhipHitboxClippedByWall();
testWhipHitboxFreeAtFullRange();
testWhipHitsMummyInRange();
testWhipNoHitOutOfRange();
testWhipSingleHitPerSwing();
if (g_failures == 0) {
std::cout << "ALL OK\n";
return EXIT_SUCCESS;
}
std::cerr << g_failures << " failures\n";
return EXIT_FAILURE;
}
+21
View File
@@ -0,0 +1,21 @@
// Stubs de Player per al binari `test_whip_attack`.
//
// El path de friendly fire del `WhipAttack` referencia
// `Player::enterHurt(Cardinal)` i `Player::getBaseBox() const`. Linkar
// `player.cpp` arrossegaria tot l'stack del jugador (audio, gamepad,
// input, screen, resource_cache…), una explosió de dependències per a
// uns tests que no exerciten aqueix camí. Aquests stubs proporcionen
// els símbols com a no-op perquè el test del whip pugui linkar i centrar
// la cobertura en hitbox + Mummy.
//
// Si algun dia volem cobrir friendly fire, caldrà o bé un binari de test
// amb tot l'stack o invertir la dependència via callable injectable
// (refactor de `WhipAttack::applyDamage`).
#include "game/entities/player.hpp"
void Player::enterHurt(Cardinal /*preferred*/) {
}
auto Player::getBaseBox() const -> SDL_FRect {
return SDL_FRect{0.0F, 0.0F, 0.0F, 0.0F};
}