diff --git a/data/entities/whip_attack/whip_attack.yaml b/data/entities/whip_attack/whip_attack.yaml index e84c1d7..38edc0b 100644 --- a/data/entities/whip_attack/whip_attack.yaml +++ b/data/entities/whip_attack/whip_attack.yaml @@ -1,15 +1,26 @@ name: whip_attack # Quatre sprites distints, un per cardinal. El sistema d'animació actual -# només permet UNA mida de frame per surface, i les hitboxes horitzontals -# (E/W: 48×16) i verticals (N/S: 16×48) tenen aspect ratios distints; per -# això són 4 fitxers en lloc d'una sola sheet compartida amb flips. -# Tots els .gif viuen al mateix directori que aquest YAML. +# 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: [48, 16] } - west: { path: whip_west.gif, size: [48, 16] } - north: { path: whip_north.gif, size: [16, 48] } - south: { path: whip_south.gif, size: [16, 48] } + 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. diff --git a/data/entities/whip_attack/whip_east.gif b/data/entities/whip_attack/whip_east.gif index eda8768..be38641 100644 Binary files a/data/entities/whip_attack/whip_east.gif and b/data/entities/whip_attack/whip_east.gif differ diff --git a/data/entities/whip_attack/whip_north.gif b/data/entities/whip_attack/whip_north.gif index 9d418e6..647f924 100644 Binary files a/data/entities/whip_attack/whip_north.gif and b/data/entities/whip_attack/whip_north.gif differ diff --git a/data/entities/whip_attack/whip_south.gif b/data/entities/whip_attack/whip_south.gif index 9d418e6..53780dc 100644 Binary files a/data/entities/whip_attack/whip_south.gif and b/data/entities/whip_attack/whip_south.gif differ diff --git a/data/entities/whip_attack/whip_west.gif b/data/entities/whip_attack/whip_west.gif index eda8768..dfacc3b 100644 Binary files a/data/entities/whip_attack/whip_west.gif and b/data/entities/whip_attack/whip_west.gif differ diff --git a/data/player/morcus.yaml b/data/player/morcus.yaml index aaa4105..716dd65 100644 --- a/data/player/morcus.yaml +++ b/data/player/morcus.yaml @@ -68,8 +68,12 @@ 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. + # 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 @@ -82,6 +86,18 @@ animations: 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`. diff --git a/data/player/sam.gif b/data/player/sam.gif index db9f574..943e292 100644 Binary files a/data/player/sam.gif and b/data/player/sam.gif differ diff --git a/data/player/sam.yaml b/data/player/sam.yaml index 2c4c3ac..31cacf2 100644 --- a/data/player/sam.yaml +++ b/data/player/sam.yaml @@ -73,17 +73,31 @@ animations: # (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] + frames: [28, 29, 30, 31] frame_ms: 50 attack_up: - frames: [4, 5, 6, 7] + frames: [24, 25, 26, 27] frame_ms: 50 attack: - frames: [8, 9, 10, 11] + 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`. diff --git a/source/game/entities/concrete/whip_attack.cpp b/source/game/entities/concrete/whip_attack.cpp index 69e49bb..0c9c6cd 100644 --- a/source/game/entities/concrete/whip_attack.cpp +++ b/source/game/entities/concrete/whip_attack.cpp @@ -89,18 +89,81 @@ WhipAttack::WhipAttack(const fkyaml::node& yaml) { anim_ = anim_swing_; } -void WhipAttack::configure(Direction dir, float spawn_x, float spawn_y, float range_px) { +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); - setPixelRect({.x = spawn_x, .y = spawn_y, .w = chosen.frame_w, .h = chosen.frame_h}); + 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* players) { entity_manager_ = manager; players_ = players; diff --git a/source/game/entities/concrete/whip_attack.hpp b/source/game/entities/concrete/whip_attack.hpp index ce975f9..6a34ed3 100644 --- a/source/game/entities/concrete/whip_attack.hpp +++ b/source/game/entities/concrete/whip_attack.hpp @@ -18,16 +18,27 @@ 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. La mida del frame -// canvia entre direccions (E/W: 48×16, N/S: 16×48), cosa que el sistema -// d'animació actual no permet expressar amb una sola surface (una entitat -// = una graella). Per això el YAML llista les 4 entrades sota `sprites:` +// 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ó. // -// Hitbox de dany: el rectangle de dany és el `pixel_rect_` retallat al -// primer bloquejador en la línia d'atac (mur extern del playfield o -// sarcòfag). Aquesta lògica viu a la fase de damage application; en -// aquest skeleton només viu la part visual i de cicle de vida. +// 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 @@ -53,12 +64,16 @@ class WhipAttack : public Attack { explicit WhipAttack(const fkyaml::node& yaml); // Configura l'estat runtime de la instància. Cal cridar-lo abans del - // primer `update`. Selecciona la surface i la mida del pixel_rect_ - // segons direcció, i posa la cantonada superior-esquerra de la - // hitbox a (spawn_x, spawn_y). També calcula la `damage_rect_` - // retallada contra murs i sarcs (mai sortiria del playfield ni - // travessaria un sarc). - void configure(Direction dir, float spawn_x, float spawn_y, float range_px); + // 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 @@ -90,6 +105,14 @@ class WhipAttack : public Attack { // 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ó @@ -123,6 +146,12 @@ class WhipAttack : public Attack { 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* players_{nullptr}; diff --git a/source/game/entities/entity.hpp b/source/game/entities/entity.hpp index 6d034ec..f7c7709 100644 --- a/source/game/entities/entity.hpp +++ b/source/game/entities/entity.hpp @@ -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 diff --git a/source/game/entities/event_bus.hpp b/source/game/entities/event_bus.hpp index ee83a23..5d414db 100644 --- a/source/game/entities/event_bus.hpp +++ b/source/game/entities/event_bus.hpp @@ -1,5 +1,7 @@ #pragma once +#include + #include #include #include @@ -57,15 +59,18 @@ struct WorldPos { // - `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. -// - `spawn_x/y` : cantonada superior-esquerra de la hitbox màxima -// (sense retallar) en píxels. -// - `range_px` : longitud de la hitbox abans de retallar contra murs i -// sarcòfags. Llegida del YAML del jugador. +// - `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}; - float spawn_x{0.0F}; - float spawn_y{0.0F}; + SDL_FRect owner_box{0.0F, 0.0F, 0.0F, 0.0F}; float range_px{0.0F}; }; diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index 7b9af55..443294f 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -660,47 +660,36 @@ void Player::enterAttacking(Cardinal facing) { // 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; + if (facing == Cardinal::NONE) { return; } + + // Mapa de Cardinal → int al payload (vegeu `WhipSpawnPayload`). 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::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(Map::tile_size); bus_->publish("whip_spawn", WhipSpawnPayload{ .owner = this, .direction = dir_int, - .spawn_x = spawn_x, - .spawn_y = spawn_y, + .owner_box = getPosition(), .range_px = RANGE_PX, }); } @@ -1141,8 +1130,12 @@ auto Player::animationName(bool moving) const -> const char* { } // 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. + // `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(whip_config.cooldown_ms)); if (IN_SWING) { @@ -1158,13 +1151,13 @@ auto Player::animationName(bool moving) const -> const char* { } switch (facing_) { case Facing::UP: - return "stand_up"; + return "recover_up"; case Facing::DOWN: - return "stand_down"; + return "recover_down"; case Facing::SIDE: break; } - return "stand"; + return "recover"; } switch (facing_) { case Facing::UP: diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index b718fed..f6c77ac 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -904,8 +904,7 @@ void GameScene::spawnWhip(const WhipSpawnPayload& payload) { whip->setOwner(payload.owner); whip->configure( static_cast(payload.direction), - payload.spawn_x, - payload.spawn_y, + 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ò diff --git a/tests/test_whip_attack.cpp b/tests/test_whip_attack.cpp index a5e572c..9b25b3d 100644 --- a/tests/test_whip_attack.cpp +++ b/tests/test_whip_attack.cpp @@ -32,100 +32,179 @@ namespace { #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, float spawn_x, float spawn_y, float range_px) + auto makeWhip(WhipAttack::Direction dir, const SDL_FRect& owner_box, float range_px) -> std::unique_ptr { auto yaml = EntityLoader::loadTemplate("whip_attack"); auto whip = std::make_unique(yaml); - whip->configure(dir, spawn_x, spawn_y, range_px); + whip->configure(dir, owner_box, range_px); return whip; } - void testWhipPixelRectPerDirection() { - std::cout << "[W1] WhipAttack pixel_rect per direcció\n"; + void testWhipDrawRectPerDirection() { + std::cout << "[W1] WhipAttack draw_rect per direcció\n"; Map::loadFrom("data/maps/pyramid.yaml"); - // Tots els spawns ben dins del playfield perquè el retall no - // afecte la mida visual del sprite (pixel_rect = mida del frame - // sencer). - const float SPAWN_X = static_cast(Map::playfield_x + 64); - const float SPAWN_Y = static_cast(Map::playfield_y + 64); + // 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(Map::playfield_x + 64); + const float OY = static_cast(Map::playfield_y + 80); + const auto BOX = playerBox(OX, OY); const float RANGE = 48.0F; - const auto WE = makeWhip(WhipAttack::Direction::EAST, SPAWN_X, SPAWN_Y, RANGE); - REQUIRE(WE->getPixelRect().w == 48.0F); - REQUIRE(WE->getPixelRect().h == 16.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, SPAWN_X, SPAWN_Y, RANGE); - REQUIRE(WW->getPixelRect().w == 48.0F); - REQUIRE(WW->getPixelRect().h == 16.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, SPAWN_X, SPAWN_Y, RANGE); + const auto WN = makeWhip(WhipAttack::Direction::NORTH, BOX, RANGE); REQUIRE(WN->getPixelRect().w == 16.0F); - REQUIRE(WN->getPixelRect().h == 48.0F); + REQUIRE(WN->getPixelRect().h == 96.0F); - const auto WS = makeWhip(WhipAttack::Direction::SOUTH, SPAWN_X, SPAWN_Y, RANGE); + const auto WS = makeWhip(WhipAttack::Direction::SOUTH, BOX, RANGE); REQUIRE(WS->getPixelRect().w == 16.0F); - REQUIRE(WS->getPixelRect().h == 48.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(Map::playfield_x + 64); + const float OY = static_cast(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(Map::playfield_x + 64); + const float OY = static_cast(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] hitbox retallat per la paret del playfield\n"; + 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 10 px (o menys, segons strict overlap). + // quedar acotada a < 48 i no més enllà del mur. const float RIGHT_WALL = static_cast(Map::playfield_x + Map::playfield_w); - const float SPAWN_X = RIGHT_WALL - 10.0F; - const float SPAWN_Y = static_cast(Map::playfield_y + 64); + const float OX = RIGHT_WALL - 16.0F - 10.0F; // l'owner mateix dins + const float OY = static_cast(Map::playfield_y + 96); + const auto BOX = playerBox(OX, OY); const float RANGE = 48.0F; - const auto W = makeWhip(WhipAttack::Direction::EAST, SPAWN_X, SPAWN_Y, RANGE); + const auto W = makeWhip(WhipAttack::Direction::EAST, BOX, RANGE); const auto DMG = W->getDamageRect(); - // Damage rect estrictament menor que el rang complet. REQUIRE(DMG.w < RANGE); - // I no més enllà de la paret. REQUIRE(DMG.x + DMG.w <= RIGHT_WALL); } void testWhipHitboxFreeAtFullRange() { - std::cout << "[W3] hitbox sense bloquejador → longitud completa\n"; + std::cout << "[W3] damage_rect sense bloquejador → longitud completa\n"; Map::loadFrom("data/maps/pyramid.yaml"); - // Spawn molt a l'interior i range curt: cap bloquejador a - // l'abast → la longitud efectiva ha de ser RANGE sencer. - const float SPAWN_X = static_cast(Map::playfield_x + 32); - const float SPAWN_Y = static_cast(Map::playfield_y + 96); + // Owner ben dins del playfield, range curt → 16 px lliures fins + // a topar amb res. La longitud efectiva = RANGE. + const float OX = static_cast(Map::playfield_x + 32); + const float OY = static_cast(Map::playfield_y + 96); + const auto BOX = playerBox(OX, OY); const float RANGE = 16.0F; - const auto W = makeWhip(WhipAttack::Direction::SOUTH, SPAWN_X, SPAWN_Y, RANGE); + 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 i es marca a alreadyHit\n"; + 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_yaml); mummy_owned->setCollisionBox({.offset_x = 0, .offset_y = 12, .width = 12, .height = 12}); - // Posicionem la mòmia 24 px a la dreta del whip → dins de range_px. - const float SPAWN_X = static_cast(Map::playfield_x + 64); - const float SPAWN_Y = static_cast(Map::playfield_y + 64); - mummy_owned->setPixelRect({.x = SPAWN_X + 24.0F, .y = SPAWN_Y - 12.0F, .w = 12.0F, .h = 24.0F}); + + // 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(Map::playfield_x + 64); + const float OY = static_cast(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(manager.add(std::move(mummy_owned))); - auto whip = makeWhip(WhipAttack::Direction::EAST, SPAWN_X, SPAWN_Y, 48.0F); + auto whip = makeWhip(WhipAttack::Direction::EAST, BOX, 48.0F); whip->setWorldContext(&manager, nullptr); - // Avancem l'animació fins arribar a un frame actiu (el YAML - // declara `active_frames: [1, 2]`; cada frame dura 50 ms). - // Amb 1 sol update de 0.06 s passem el frame 0 → 1. + // 60 ms → frame 1 (active). active_frames: [1, 2]. whip->update(0.06F); REQUIRE(mummy_raw->getState() == Mummy::State::HURT); @@ -139,13 +218,19 @@ namespace { auto mummy_yaml = EntityLoader::loadTemplate("mummy"); auto mummy_owned = std::make_unique(mummy_yaml); mummy_owned->setCollisionBox({.offset_x = 0, .offset_y = 12, .width = 12, .height = 12}); - const float SPAWN_X = static_cast(Map::playfield_x + 32); - const float SPAWN_Y = static_cast(Map::playfield_y + 64); - // Mòmia 96 px a la dreta → fora del rang de 32. - mummy_owned->setPixelRect({.x = SPAWN_X + 96.0F, .y = SPAWN_Y - 12.0F, .w = 12.0F, .h = 24.0F}); + + // 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(Map::playfield_x + 64); + const float OY = static_cast(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(manager.add(std::move(mummy_owned))); - auto whip = makeWhip(WhipAttack::Direction::EAST, SPAWN_X, SPAWN_Y, 32.0F); + auto whip = makeWhip(WhipAttack::Direction::EAST, BOX, 32.0F); whip->setWorldContext(&manager, nullptr); whip->update(0.06F); @@ -160,30 +245,37 @@ namespace { auto mummy_yaml = EntityLoader::loadTemplate("mummy"); auto mummy_owned = std::make_unique(mummy_yaml); mummy_owned->setCollisionBox({.offset_x = 0, .offset_y = 12, .width = 12, .height = 12}); - const float SPAWN_X = static_cast(Map::playfield_x + 64); - const float SPAWN_Y = static_cast(Map::playfield_y + 64); - mummy_owned->setPixelRect({.x = SPAWN_X + 24.0F, .y = SPAWN_Y - 12.0F, .w = 12.0F, .h = 24.0F}); + + // 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(Map::playfield_x + 64); + const float OY = static_cast(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(manager.add(std::move(mummy_owned))); - auto whip = makeWhip(WhipAttack::Direction::EAST, SPAWN_X, SPAWN_Y, 48.0F); + auto whip = makeWhip(WhipAttack::Direction::EAST, BOX, 48.0F); whip->setWorldContext(&manager, nullptr); - // Primera passada: mòmia a HURT (el whip entra al frame 1 - // = actiu en el primer step de 0.06 s). + // 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. + // 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 - // Si re-hit, enterHurt restauraria el timer; alreadyHit ho impedeix. REQUIRE(mummy_raw->getHurtRemainingS() == HURT_AFTER_MUMMY_TICK); } @@ -192,7 +284,9 @@ namespace { auto main() -> int { std::cout << "== smoke tests: WhipAttack ==\n"; - testWhipPixelRectPerDirection(); + testWhipDrawRectPerDirection(); + testWhipDrawRectOffsets(); + testWhipFeetYDelegation(); testWhipHitboxClippedByWall(); testWhipHitboxFreeAtFullRange(); testWhipHitsMummyInRange();