diff --git a/CMakeLists.txt b/CMakeLists.txt index 49bcd8b..a6a777c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -47,6 +47,7 @@ set(ENTITY_SOURCES source/game/entities/concrete/rolling_rock.cpp source/game/entities/concrete/wall_trap.cpp source/game/entities/concrete/floor_trap.cpp + source/game/entities/concrete/dust.cpp ) set(APP_SOURCES diff --git a/data/entities/door/door.yaml b/data/entities/door/door.yaml index bf86231..fabd3d8 100644 --- a/data/entities/door/door.yaml +++ b/data/entities/door/door.yaml @@ -4,6 +4,10 @@ sprite: path: door.gif size: [48, 32] tile_size: [3, 2] +collision: + # Porta = mur infranquejable. Igual que el sarc, alçada efectiva + # infinita per impedir que un jugador la salte per damunt. + height: 999 flags: collides: true interactable: true diff --git a/data/entities/dust/dust.gif b/data/entities/dust/dust.gif new file mode 100644 index 0000000..5aa1062 Binary files /dev/null and b/data/entities/dust/dust.gif differ diff --git a/data/entities/dust/dust.yaml b/data/entities/dust/dust.yaml new file mode 100644 index 0000000..612cd5b --- /dev/null +++ b/data/entities/dust/dust.yaml @@ -0,0 +1,22 @@ +name: dust +type: effect +sprite: + path: dust.gif + size: [40, 16] + +# El polvet és purament visual: no col·lisiona, no interactua, no fa +# dany. Només viu mentre dura l'animació triada (`jump` o `land`) i +# desapareix per `markDead` quan acaba. +flags: + collides: false + interactable: false + +# Dues animacions, no-loop: una per a cada moment del salt. El spawner +# (GameScene, en resposta a events del bus) instancia la variant +# apropiada via `setVariant` abans del primer tick. +# jump: frames 0..6 (7 frames) → reproduït al despegue. +# land: frames 7..12 (6 frames) → reproduït a l'aterratge. +# fps: 10 = 100 ms per frame. +animations: + jump: { frames: [0, 1, 2, 3, 4, 5, 6], fps: 15, loop: false } + land: { frames: [7, 8, 9, 10, 11, 12], fps: 15, loop: false } diff --git a/data/entities/floor_trap/floor_trap.yaml b/data/entities/floor_trap/floor_trap.yaml index e7b2aa1..d99c006 100644 --- a/data/entities/floor_trap/floor_trap.yaml +++ b/data/entities/floor_trap/floor_trap.yaml @@ -4,6 +4,11 @@ sprite: path: floor_trap.gif size: [16, 16] tile_size: [1, 1] +collision: + # Trampa al sòl: alçada 0 → qualsevol salt l'esquiva. (Avui amb + # `collides: false` la trampa no entra al filtre de contacte, però + # declarem el camp per quan es cablege la detecció via àrea.) + height: 0 flags: collides: false interactable: false diff --git a/data/entities/mummy/mummy.yaml b/data/entities/mummy/mummy.yaml index f271a60..41f6bf3 100644 --- a/data/entities/mummy/mummy.yaml +++ b/data/entities/mummy/mummy.yaml @@ -14,6 +14,11 @@ anchor: feet collision: size: [16, 16] base_size: [16, 16] + # Alçada del cos en píxels per al filtre de col·lisió per salt: un + # jugador amb jump_z > height no col·lisiona amb la momia (la salta + # per damunt). Amb impulse=240 + gravity=600 el pic ronda els 48 px, + # així que 24 deixa marge per esquivar amb un salt no perfecte. + height: 24 flags: collides: true interactable: false diff --git a/data/entities/rolling_rock/rolling_rock.yaml b/data/entities/rolling_rock/rolling_rock.yaml index 326f5b5..95f3745 100644 --- a/data/entities/rolling_rock/rolling_rock.yaml +++ b/data/entities/rolling_rock/rolling_rock.yaml @@ -4,6 +4,11 @@ sprite: path: rolling_rock.gif size: [16, 16] tile_size: [1, 1] +collision: + # `size`/`base_size` no calen per a la roca (tot el sprite és cos), + # però sí declarem `height` per al filtre de salt: 16 px → fàcil + # d'esquivar amb un salt curt. + height: 16 flags: collides: true interactable: false diff --git a/data/entities/sarcophagus/sarcophagus.yaml b/data/entities/sarcophagus/sarcophagus.yaml index 7193f62..ab2e2ff 100644 --- a/data/entities/sarcophagus/sarcophagus.yaml +++ b/data/entities/sarcophagus/sarcophagus.yaml @@ -7,6 +7,13 @@ sprite: # són "alçada" en falsa perspectiva, sense col·lisió. Perímetre ortogonal # de 8 tiles (3 N + 3 S + 1 O + 1 E). tile_size: [3, 1] +collision: + # Sarcòfag = obstacle massís: alçada efectiva infinita (999) per al + # filtre del salt. Un jugador no el pot saltar per damunt encara que + # el salt arribe al punt àlgid. La caixa de col·lisió real (logical + # 48×16) la calcula `placeSarcophagus` a partir de la geometria del + # mapa, no des d'aquest YAML. + height: 999 flags: collides: true interactable: true diff --git a/data/entities/torch/torch.yaml b/data/entities/torch/torch.yaml index 6b00340..b659ead 100644 --- a/data/entities/torch/torch.yaml +++ b/data/entities/torch/torch.yaml @@ -4,6 +4,10 @@ sprite: path: torch.gif size: [16, 16] tile_size: [1, 1] +collision: + # Decoratiu, no col·lisiona. Camp `height` declarat per coherència + # amb la resta d'entitats; no té efecte mentre `collides: false`. + height: 0 flags: collides: false interactable: false diff --git a/data/entities/wall_trap/wall_trap.yaml b/data/entities/wall_trap/wall_trap.yaml index 2ef051b..39461c1 100644 --- a/data/entities/wall_trap/wall_trap.yaml +++ b/data/entities/wall_trap/wall_trap.yaml @@ -4,6 +4,11 @@ sprite: path: wall_trap.gif size: [16, 16] tile_size: [1, 1] +collision: + # Trampa de paret: ix d'una vora alta i no es pot saltar per damunt. + # 999 = infranquejable. Igual que floor_trap, el filtre només té efecte + # quan la detecció de contacte hi entre. + height: 999 flags: collides: false interactable: false diff --git a/data/player/morcus.gif b/data/player/morcus.gif index d4caa57..97b32b8 100644 Binary files a/data/player/morcus.gif and b/data/player/morcus.gif differ diff --git a/data/player/morcus.yaml b/data/player/morcus.yaml index 6911f45..9f6806d 100644 --- a/data/player/morcus.yaml +++ b/data/player/morcus.yaml @@ -15,6 +15,16 @@ collision: # `size` es reserva per a la col·lisió amb murs/sarcòfags i per enfilar # els passadissos. Centrada en el mateix punt que la caixa base. hit_size: 8 + # Alçada del cos en píxels. Funció DOBLE segons amb què col·lisione: + # · Contra entitats (mummy, rolling_rock, sarc): si `jump_z > height` + # d'aquella entitat, no hi ha col·lisió. Igual que als YAMLs + # d'entitats. + # · Contra altres jugadors: la col·lisió és binària per estat aeri. + # Si un està en l'aire i l'altre no → cap col·lisió. Si tots dos + # estan en l'aire, llavors `body_height` decideix si els rangs + # verticals solapen i s'aplica el rebot mid-aire. `0` = sense cos + # (ni rebot quan tots dos volen). + height: 12 # Animacions en mapa (clau = nom). Camps per animació: # frames → llista d'índexs dins de l'spritesheet. @@ -27,6 +37,12 @@ animations: stand_down: { frames: [0] } stand_up: { frames: [4] } stand: { frames: [8] } + jump_down: { frames: [12] } + jump_up: { frames: [13] } + jump: { frames: [14] } + stun_down: { frames: [16] } + stun_up: { frames: [17] } + stun: { frames: [18] } walk_down: frames: [0, 1, 2, 3] @@ -40,6 +56,18 @@ animations: frames: [8, 9, 10, 11] frame_ms: 100 + hurt_down: + frames: [0, 1, 2, 3] + frame_ms: 100 + + hurt_up: + frames: [4, 5, 6, 7] + frame_ms: 100 + + hurt: + frames: [8, 9, 10, 11] + frame_ms: 100 + # 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`. @@ -64,3 +92,38 @@ turn: east_to_west: up # passa per dalt (stand_up) north_to_south: east # passa per la dreta (stand, sense flip) south_to_north: west # passa per l'esquerra (stand, amb flip) + +# Salt vertical (px verticals "visuals", no afecta la posició al món). +# impulse → velocitat vertical inicial (px/s) en deixar el sòl. +# gravity → acceleració descendent constant (px/s²). +# landing_stun_ms → temps en què el jugador queda paralitzat en aterrar +# (no es pot moure ni tornar a saltar). +# shadow_y_offset → desplaçament vertical de la sombra (px, positiu = +# puja). Compensa la franja transparent que els +# sprites del jugador tenen sota els peus reals +# perquè camine "pel mig del passadís" (3 px aquí). +# sound → fitxer .wav reproduït al despegue (relatiu a +# data/sounds/). Buit o absent = sense so. +# land_sound → fitxer .wav reproduït en aterrar. +# L'alçada del cos viu al bloc `collision:` (`height`), igual que la resta +# d'entitats: és una propietat de col·lisió, no de la física del salt. +# Amb impulse=240 i gravity=600: pic ≈ 48 px, temps total en l'aire ≈ 0.8 s. +jump: + impulse: 200 + gravity: 600 + landing_stun_ms: 400 + shadow_y_offset: 3 + sound: jump.wav + land_sound: land.wav + +# Estat HURT: el jugador queda projectat sense control per un impacte +# (algú que cau al damunt; en el futur també fuetada de l'altre jugador). +# `enterHurt` tria automàticament la direcció amb més recorregut lliure +# entre els 4 cardinals, posa la velocitat inicial a `speed` i la fa +# decaure linealment fins a 0 en `brake_time_ms`. Si pel camí xoca amb +# un mur, para en sec. En tornar a velocitat 0, recupera l'estat +# GROUNDED i el control. Mentre dura, no col·lisiona amb altres jugadors. +# Amb speed=120 i brake_time=500ms ⇒ recorregut ≈ 30 px (≈2 tiles). +hurt: + speed: 120 + brake_time_ms: 500 diff --git a/data/player/sam.gif b/data/player/sam.gif index 98eaa5d..db9f574 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 3481f3e..0b22f7f 100644 --- a/data/player/sam.yaml +++ b/data/player/sam.yaml @@ -15,6 +15,16 @@ collision: # `size` es reserva per a la col·lisió amb murs/sarcòfags i per enfilar # els passadissos. Centrada en el mateix punt que la caixa base. hit_size: 8 + # Alçada del cos en píxels. Funció DOBLE segons amb què col·lisione: + # · Contra entitats (mummy, rolling_rock, sarc): si `jump_z > height` + # d'aquella entitat, no hi ha col·lisió. Igual que als YAMLs + # d'entitats. + # · Contra altres jugadors: la col·lisió és binària per estat aeri. + # Si un està en l'aire i l'altre no → cap col·lisió. Si tots dos + # estan en l'aire, llavors `body_height` decideix si els rangs + # verticals solapen i s'aplica el rebot mid-aire. `0` = sense cos + # (ni rebot quan tots dos volen). + height: 12 # Animacions en mapa (clau = nom). Camps per animació: # frames → llista d'índexs dins de l'spritesheet. @@ -27,6 +37,12 @@ animations: stand_down: { frames: [1] } stand_up: { frames: [5] } stand: { frames: [9] } + jump_down: { frames: [12] } + jump_up: { frames: [13] } + jump: { frames: [14] } + stun_down: { frames: [16] } + stun_up: { frames: [17] } + stun: { frames: [18] } walk_down: frames: [0, 1, 2, 3] @@ -40,6 +56,18 @@ animations: frames: [8, 9, 10, 11] frame_ms: 100 + hurt_down: + frames: [0, 1, 2, 3] + frame_ms: 100 + + hurt_up: + frames: [4, 5, 6, 7] + frame_ms: 100 + + hurt: + frames: [8, 9, 10, 11] + frame_ms: 100 + # 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`. @@ -64,3 +92,38 @@ turn: east_to_west: up # passa per dalt (stand_up) north_to_south: east # passa per la dreta (stand, sense flip) south_to_north: west # passa per l'esquerra (stand, amb flip) + +# Salt vertical (px verticals "visuals", no afecta la posició al món). +# impulse → velocitat vertical inicial (px/s) en deixar el sòl. +# gravity → acceleració descendent constant (px/s²). +# landing_stun_ms → temps en què el jugador queda paralitzat en aterrar +# (no es pot moure ni tornar a saltar). +# shadow_y_offset → desplaçament vertical de la sombra (px, positiu = +# puja). Compensa la franja transparent que els +# sprites del jugador tenen sota els peus reals +# perquè camine "pel mig del passadís" (3 px aquí). +# sound → fitxer .wav reproduït al despegue (relatiu a +# data/sounds/). Buit o absent = sense so. +# land_sound → fitxer .wav reproduït en aterrar. +# L'alçada del cos viu al bloc `collision:` (`height`), igual que la resta +# d'entitats: és una propietat de col·lisió, no de la física del salt. +# Amb impulse=240 i gravity=600: pic ≈ 48 px, temps total en l'aire ≈ 0.8 s. +jump: + impulse: 200 + gravity: 600 + landing_stun_ms: 400 + shadow_y_offset: 3 + sound: jump.wav + land_sound: land.wav + +# Estat HURT: el jugador queda projectat sense control per un impacte +# (algú que cau al damunt; en el futur també fuetada de l'altre jugador). +# `enterHurt` tria automàticament la direcció amb més recorregut lliure +# entre els 4 cardinals, posa la velocitat inicial a `speed` i la fa +# decaure linealment fins a 0 en `brake_time_ms`. Si pel camí xoca amb +# un mur, para en sec. En tornar a velocitat 0, recupera l'estat +# GROUNDED i el control. Mentre dura, no col·lisiona amb altres jugadors. +# Amb speed=120 i brake_time=500ms ⇒ recorregut ≈ 30 px (≈2 tiles). +hurt: + speed: 120 + brake_time_ms: 500 diff --git a/data/sounds/jump.wav b/data/sounds/jump.wav new file mode 100644 index 0000000..920e27f Binary files /dev/null and b/data/sounds/jump.wav differ diff --git a/data/sounds/land.wav b/data/sounds/land.wav new file mode 100644 index 0000000..41867f6 Binary files /dev/null and b/data/sounds/land.wav differ diff --git a/source/game/entities/concrete/dust.cpp b/source/game/entities/concrete/dust.cpp new file mode 100644 index 0000000..29aedc5 --- /dev/null +++ b/source/game/entities/concrete/dust.cpp @@ -0,0 +1,57 @@ +#include "game/entities/concrete/dust.hpp" + +#include +#include + +#include "game/entities/entity_factory.hpp" + +namespace { + + // Llig una animació opcional del bloc `animations` del YAML, amb + // valors per defecte segurs (1 frame, fps=1, sense loop). Si la + // clau no existeix, retornem una animació "ja completada" — així, + // un Dust spawnejat amb una variant inexistent al YAML mor al + // pròxim frame sense petar. + auto buildAnim(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent { + std::vector frames = {0}; + float fps = 1.0F; + bool loop = false; + if (yaml.contains("animations") && yaml["animations"].contains(key)) { + const auto& anim = yaml["animations"][key]; + if (anim.contains("frames")) { + frames.clear(); + for (const auto& f : anim["frames"]) { + // cppcheck-suppress useStlAlgorithm + frames.push_back(f.get_value()); + } + if (frames.empty()) { frames.push_back(0); } + } + if (anim.contains("fps")) { fps = anim["fps"].get_value(); } + if (anim.contains("loop")) { loop = anim["loop"].get_value(); } + } + return {frames, fps, loop}; + } + +} // namespace + +Dust::Dust(const fkyaml::node& yaml) + : anim_jump_(buildAnim(yaml, "jump")), + anim_land_(buildAnim(yaml, "land")) { + name_ = yaml.contains("name") ? yaml["name"].get_value() : std::string("dust"); + collides_ = false; + interactable_ = false; + // Per defecte JUMP; el spawner pot canviar-la abans del primer tick. + anim_ = anim_jump_; +} + +void Dust::setVariant(Variant v) { + anim_ = (v == Variant::JUMP) ? anim_jump_ : anim_land_; + if (anim_.has_value()) { anim_->reset(); } +} + +void Dust::update(float delta_time) { + Entity::update(delta_time); + if (anim_.has_value() && anim_->completed()) { markDead(); } +} + +REGISTER_ENTITY("dust", Dust) diff --git a/source/game/entities/concrete/dust.hpp b/source/game/entities/concrete/dust.hpp new file mode 100644 index 0000000..169ed14 --- /dev/null +++ b/source/game/entities/concrete/dust.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include + +#include "external/fkyaml_node.hpp" +#include "game/entities/animation_component.hpp" +#include "game/entities/entity.hpp" + +// Polvet transitori: efecte purament visual que viu una animació i +// s'autoelimina (`markDead`) quan acaba. Spawneja el `GameScene` com a +// reacció a events `player_jumped` i `player_landed` del bus. Les dues +// variants (despegue / aterratge) viuen al mateix YAML amb dues +// animacions distintes (`jump` i `land`); l'instància tria una via +// `setVariant` just després de construir-se. +class Dust : public Entity { + public: + enum class Variant : std::uint8_t { + JUMP, + LAND, + }; + + explicit Dust(const fkyaml::node& yaml); + + // Tria quina animació es reproduirà. Cal cridar-lo abans del primer + // `update`; per defecte la variant és JUMP. + void setVariant(Variant v); + + // Tickeja l'animació activa. Quan arriba a `completed()`, marquem + // l'entitat morta perquè l'`EntityManager` la purgue al final del + // frame. + void update(float delta_time) override; + + private: + AnimationComponent anim_jump_; + AnimationComponent anim_land_; +}; diff --git a/source/game/entities/entity.hpp b/source/game/entities/entity.hpp index 66ebf8f..6d034ec 100644 --- a/source/game/entities/entity.hpp +++ b/source/game/entities/entity.hpp @@ -88,6 +88,14 @@ class Entity { [[nodiscard]] auto collides() const -> bool { return collides_; } [[nodiscard]] auto interactable() const -> bool { return interactable_; } + // Marca per a entitats transitòries: efectes (polvet, espurnes, + // explosions...) que viuen una sola animació i s'autoeliminen quan + // acaba. L'`EntityManager` purga totes les entitats amb `dead_=true` + // al final de cada `update`. Les entitats persistents (sarcs, momies, + // portes, jugadors) mai no la posen i hi viuen sempre. + [[nodiscard]] auto isDead() const -> bool { return dead_; } + void markDead() { dead_ = true; } + // Associar el bus una sola vegada (propietat externa, p.ex. // `EntityManager`). Entitats que no publiquen events (Torch, trampes // estàtiques de moment) el poden ignorar. @@ -113,6 +121,17 @@ class Entity { // Entitats sense animació retornen `nullopt` → frame 0 per defecte. [[nodiscard]] auto getAnimation() const -> const std::optional& { return anim_; } + // Alçada del cos de l'entitat en píxels. La consulten els filtres + // de col·lisió per altura: si un jugador en l'aire passa per damunt + // d'aquest valor (jump_z_ > height), no col·lisiona amb l'entitat — + // és la mecànica del salt per esquivar momies, roques, etc. Valor + // 0 = sense altura (no es pot esquivar saltant; per a entitats amb + // collides=false el camp és irrellevant). Per a obstacles que + // representen "murs" (sarcs, portes), el YAML els pot donar un + // valor molt alt (p.ex. 999) per fer-los infranquejables. + [[nodiscard]] auto getHeight() const -> int { return height_; } + void setHeight(int h) { height_ = h; } + // Caixa de col·lisió: per defecte és el `pixel_rect_` sencer (entitats // AABB-aligned). Si s'ha configurat un `CollisionBox`, retorna el // sub-rect dins del sprite — vida útil: validar col·lisions amb el @@ -150,6 +169,8 @@ class Entity { std::shared_ptr surface_{nullptr}; bool collides_{false}; bool interactable_{false}; + int height_{0}; + bool dead_{false}; SDL_FlipMode flip_{SDL_FLIP_NONE}; EventBus* bus_{nullptr}; }; diff --git a/source/game/entities/entity_manager.cpp b/source/game/entities/entity_manager.cpp index dbfeed7..01125fd 100644 --- a/source/game/entities/entity_manager.cpp +++ b/source/game/entities/entity_manager.cpp @@ -64,7 +64,16 @@ auto EntityManager::addSarcophagus(std::unique_ptr sarc) -> Sarcoph } void EntityManager::update(float delta_time) { + // ATENCIÓ: si en el futur una entitat publica al bus events que + // resulten en `entity_manager_.add()` (push_back a `entities_`), açò + // invalidaria l'iterador de la ranged-for. Avui no passa: els spawns + // runtime venen de fora del cicle d'`update` (p.ex. el polvet del + // saltador es spawneja durant `Player::tick`, abans d'aquest mètode). for (auto& e : entities_) { e->update(delta_time); } + // Purga d'entitats marcades mortes aquest frame (efectes acabats, + // p.ex. polvet d'aterratge). Les persistents (sarcs, momies, etc.) + // mai no es marquen. + std::erase_if(entities_, [](const auto& e) { return e->isDead(); }); } void EntityManager::notifyPlayerTilePainted(int col, int row) { diff --git a/source/game/entities/entity_manager_loadlevel.cpp b/source/game/entities/entity_manager_loadlevel.cpp index 4d26778..c4b1cc7 100644 --- a/source/game/entities/entity_manager_loadlevel.cpp +++ b/source/game/entities/entity_manager_loadlevel.cpp @@ -63,8 +63,15 @@ void EntityManager::checkPlayerCollisions(const std::vector& players) { for (const auto& entity : entities_) { if (!entity->collides()) { continue; } const auto BOX = entity->getCollisionRect(); + const int E_HEIGHT = entity->getHeight(); for (auto* p : players) { if (p == nullptr) { continue; } + // Filtre per altura: si el jugador està en l'aire i està per + // damunt de l'alçada del cos de l'entitat, no hi ha contacte. + // És el mecanisme del salt per esquivar momies, roques, etc. + // Quan height és 0 (entitat sense altura declarada), el filtre + // no es dispara mai i la col·lisió funciona com abans. + if (p->isAirborne() && E_HEIGHT > 0 && p->getJumpZ() > static_cast(E_HEIGHT)) { continue; } // Contacte actor-actor amb hit-box petita: la caixa base // 16×16 servia per moure (xoc amb murs), però per a l'event // "toca una entitat" volem precisió — que la momia mate quan @@ -109,6 +116,18 @@ namespace { return info; } + // Llig `collision.height` (alçada del cos en píxels) de la plantilla. + // 0 si no està declarada — ho usen els filtres de col·lisió per altura + // del salt: un jugador per damunt d'aquest valor no col·lisiona amb + // l'entitat. Per a obstacles "tipus mur" (sarc, porta) el YAML els + // dóna un valor molt alt per fer-los infranquejables. + auto readHeight(const fkyaml::node& tmpl) -> int { + if (!tmpl.contains("collision")) { return 0; } + const auto& c = tmpl["collision"]; + if (!c.contains("height")) { return 0; } + return c["height"].get_value(); + } + // Bloc `collision: { size: [w, h], base_size: [bw, bh]?, offset: [x, y]? }` // de la plantilla d'entitat. // - Sense `base_size` ni `offset`: la caja queda alineada a la vora @@ -212,6 +231,7 @@ namespace { if (const auto BOX = readCollisionBox(tmpl, sprite_out.width, sprite_out.height); BOX.has_value()) { entity->setCollisionBox(*BOX); } + entity->setHeight(readHeight(tmpl)); anchor_out = readAnchor(tmpl); return entity; } diff --git a/source/game/entities/event_bus.hpp b/source/game/entities/event_bus.hpp index fd7789e..88b8e64 100644 --- a/source/game/entities/event_bus.hpp +++ b/source/game/entities/event_bus.hpp @@ -41,9 +41,17 @@ struct PaintedTile { int tile_index{-1}; }; +// Coordenades en píxels al canvas. Payload genèric per a events que +// volen indicar "una cosa ha passat aquí" — actualment `player_jumped` +// i `player_landed`, que el GameScene escolta per spawnejar polvet. +struct WorldPos { + float x{0.0F}; + float y{0.0F}; +}; + struct Event { std::string name; - using Payload = std::variant; + using Payload = std::variant; 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 @@ -90,6 +98,9 @@ class EventBus { void publish(const std::string& name, PaintedTile painted) { publish(Event{.name = name, .payload = painted}); } + void publish(const std::string& name, WorldPos pos) { + publish(Event{.name = name, .payload = pos}); + } private: struct Subscription { diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index 170311b..fc97b5c 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -3,12 +3,15 @@ #include #include +#include #include #include #include +#include "core/audio/audio.hpp" #include "core/input/gamepad_manager.hpp" #include "core/input/input_state.hpp" +#include "core/rendering/screen.hpp" #include "core/resources/resource_cache.hpp" #include "core/resources/resource_helper.hpp" #include "external/fkyaml_node.hpp" @@ -63,6 +66,8 @@ namespace { Player::TurnConfig Player::turn_config; Player::InertiaConfig Player::inertia_config; +Player::JumpConfig Player::jump_config; +Player::HurtConfig Player::hurt_config; void Player::loadFrom(const std::string& yaml_path) { auto bytes = Resource::Helper::loadFile(yaml_path); @@ -75,28 +80,7 @@ void Player::loadFrom(const std::string& yaml_path) { readSpriteSize(yaml, yaml_path, sprite_w, sprite_h); - if (!yaml.contains("collision")) { - throw std::runtime_error("Player: manca bloc `collision:` a " + yaml_path); - } - const auto& c = yaml["collision"]; - collision_size = c["size"].get_value(); - base_size = c["base_size"].get_value(); - - // Col·lisió centrada en el quadrat inferior del sprite. Si la caixa és - // més gran que la base (cas habitual, 16 vs 12) el padding és simètric - // a cada costat; si són iguals, padding=0 i la caixa coincideix amb la - // base sense offsets. - collision_padding = (collision_size - base_size) / 2; - collision_x_offset = -collision_padding; - collision_y_offset = (sprite_h - base_size) - collision_padding; - - // Hit-box opcional (actor-actor). Si no hi és, s'iguala a la base - // per preservar compatibilitat amb YAMLs antics. Concèntrica amb la - // caixa de col·lisió: offset interior = (collision_size - hit_size) / 2. - hit_size = c.contains("hit_size") ? c["hit_size"].get_value() : collision_size; - const int HIT_INSET = (collision_size - hit_size) / 2; - hit_x_offset = collision_x_offset + HIT_INSET; - hit_y_offset = collision_y_offset + HIT_INSET; + readCollisionBlock(yaml, yaml_path); // Bloc opcional `inertia:` — si manca, `brake_time_ms` queda a 0 i // la inèrcia de frenada no s'activa (parada instantània com abans). @@ -121,6 +105,74 @@ void Player::loadFrom(const std::string& yaml_path) { if (tr.contains("south_to_north")) { turn_config.s_to_n = cardinalFromString(tr["south_to_north"].get_value()); } } } + + // Bloc opcional `jump:` — si manca, `impulse_px_per_s` queda a 0 i el + // botó de salt no fa res. Extret a un mètode estàtic per acotar la + // complexitat cognitiva de `loadFrom` sota el llindar de clang-tidy. + readJumpBlock(yaml); + + // Bloc opcional `hurt:` — estat post-impacte. Si manca o el speed + // és 0, l'estat HURT mai s'activa (cas degenerat). + readHurtBlock(yaml); +} + +void Player::readCollisionBlock(const fkyaml::node& yaml, const std::string& yaml_path) { + if (!yaml.contains("collision")) { + throw std::runtime_error("Player: manca bloc `collision:` a " + yaml_path); + } + const auto& c = yaml["collision"]; + collision_size = c["size"].get_value(); + base_size = c["base_size"].get_value(); + + // Col·lisió centrada en el quadrat inferior del sprite. Si la caixa és + // més gran que la base (cas habitual, 16 vs 12) el padding és simètric + // a cada costat; si són iguals, padding=0 i la caixa coincideix amb la + // base sense offsets. + collision_padding = (collision_size - base_size) / 2; + collision_x_offset = -collision_padding; + collision_y_offset = (sprite_h - base_size) - collision_padding; + + // Hit-box opcional (actor-actor). Si no hi és, s'iguala a la base + // per preservar compatibilitat amb YAMLs antics. Concèntrica amb la + // caixa de col·lisió: offset interior = (collision_size - hit_size) / 2. + hit_size = c.contains("hit_size") ? c["hit_size"].get_value() : collision_size; + const int HIT_INSET = (collision_size - hit_size) / 2; + hit_x_offset = collision_x_offset + HIT_INSET; + hit_y_offset = collision_y_offset + HIT_INSET; + + // Alçada del cos, llegida del mateix bloc per coherència amb la + // resta d'entitats (mummy, sarcophagus, etc.). 0 = sense filtre per + // altura — el salt no esquiva l'altre jugador. + body_height = c.contains("height") ? c["height"].get_value() : 0; +} + +void Player::readJumpBlock(const fkyaml::node& yaml) { + jump_config = JumpConfig{}; + if (!yaml.contains("jump")) { return; } + const auto& j = yaml["jump"]; + if (j.contains("impulse")) { jump_config.impulse_px_per_s = j["impulse"].get_value(); } + if (j.contains("gravity")) { jump_config.gravity_px_per_s2 = j["gravity"].get_value(); } + if (j.contains("landing_stun_ms")) { jump_config.landing_stun_ms = j["landing_stun_ms"].get_value(); } + if (j.contains("shadow_y_offset")) { jump_config.shadow_y_offset = j["shadow_y_offset"].get_value(); } + // Sons opcionals: el nom del fitxer (relatiu a data/sounds/) val tant + // de clau al Resource::Cache com de basename per a la càrrega. Si el + // camp manca, la clau queda buida i `playSound` no es crida. + if (j.contains("sound")) { + jump_config.sound_key = j["sound"].get_value(); + Resource::Cache::get().loadSound(jump_config.sound_key, "data/sounds/" + jump_config.sound_key); + } + if (j.contains("land_sound")) { + jump_config.land_sound_key = j["land_sound"].get_value(); + Resource::Cache::get().loadSound(jump_config.land_sound_key, "data/sounds/" + jump_config.land_sound_key); + } +} + +void Player::readHurtBlock(const fkyaml::node& yaml) { + hurt_config = HurtConfig{}; + if (!yaml.contains("hurt")) { return; } + const auto& h = yaml["hurt"]; + if (h.contains("speed")) { hurt_config.initial_speed_px_per_s = h["speed"].get_value(); } + if (h.contains("brake_time_ms")) { hurt_config.brake_time_ms = h["brake_time_ms"].get_value(); } } void Player::applyCardinal(Cardinal c) { @@ -283,9 +335,32 @@ auto Player::collidesWithWorld(const SDL_FRect& box) const -> bool { // amb la hit-box actual de l'altre jugador. Així la caixa de moviment // (16×16) segueix topant amb murs/sarcs però els jugadors poden // acostar-se fins al contacte real. + // + // Regla de col·lisió jugador↔jugador (segons el feel triat): + // - HURT a un costat o a l'altre → cap col·lisió. El jugador + // impactat es travessa net. + // - Tots dos a terra (PvP-tangibles) → col·lisió normal. + // - Un en l'aire i l'altre no → SENSE col·lisió. El saltador es + // travessa net del caminant; ningú actua de "muro" durant el + // vol, ni a la pujada ni a la baixada. + // - Tots dos en l'aire → col·lisió només si els rangs verticals + // [jump_z, jump_z + body_height] solapen. Així permetem el xoc + // mid-aire quan tots dos van un contra l'altre i estan a la + // mateixa franja d'alçada. const SDL_FRect PROPOSED_HIT = hitBoxFromBase(box); - return std::ranges::any_of(*all_players_, [this, &PROPOSED_HIT](const Player* p) { + const bool MY_AIR = (state_ == State::AIRBORNE); + const bool MY_HURT = (state_ == State::HURT); + const float MY_Z = jump_z_; + return std::ranges::any_of(*all_players_, [this, &PROPOSED_HIT, MY_AIR, MY_HURT, MY_Z](const Player* p) { if (p == nullptr || p == this) { return false; } + if (MY_HURT || p->isHurt()) { return false; } + if (MY_AIR != p->isAirborne()) { return false; } + // Aquí els dos estats coincideixen. Si tots dos volen, el filtre + // per altura mira que els rangs verticals encara solapin; si no, + // estan a prou distància en Y "real" i no es toquen. + if (MY_AIR && body_height > 0 && std::fabs(MY_Z - p->jump_z_) >= static_cast(body_height)) { + return false; + } const auto OTHER = p->getHitBox(); return SDL_HasRectIntersectionFloat(&PROPOSED_HIT, &OTHER); }); @@ -329,6 +404,72 @@ void Player::tick(float delta_time) { // (un deixa d'empentar, l'altre comença a caminar), l'animació torna // a governar-se per la velocitat real sense estat romanent. being_pushed_ = false; + + // En LANDING_STUN, descomptem el timer i ignorem qualsevol input + // (moviment i salt). Quan s'esgota, tornem a GROUNDED al pròxim + // frame. La velocitat queda a zero — després del stun hi tornarà + // a haver intenció si el jugador prem alguna tecla. + if (state_ == State::LANDING_STUN) { + landing_stun_remaining_ms_ -= delta_time * 1000.0F; + if (landing_stun_remaining_ms_ <= 0.0F) { + landing_stun_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); + // Mantenim els flancs del botó de salt actualitzats fins i tot + // 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(); + return; + } + + // En AIRBORNE el jugador no llig input direccional: la velocitat + // horitzontal queda fixada al moment del despegue. Tampoc canvia + // facing. Sí actualitzem els flancs del botó perquè la propera + // pulsació quan torne a terra es detecte com a flanc nou. + if (state_ == State::AIRBORNE) { + (void)consumeJumpEdge(); + setVelX(intended_vx_); + setVelY(intended_vy_); + return; + } + + // En HURT el jugador està projectat sense control: descomptem + // velocitat per inèrcia (decaiment lineal fins a 0). Quan arriba a + // zero, tornem a GROUNDED i el frame següent ja torna a llegir input. + // El moviment real l'aplica `applyMovement` (igual que els altres + // estats), que també gestiona el xoc contra murs. + if (state_ == State::HURT) { + (void)consumeJumpEdge(); + const float DECEL = (hurt_config.brake_time_ms > 0) + ? hurt_config.initial_speed_px_per_s / (static_cast(hurt_config.brake_time_ms) / 1000.0F) + : 0.0F; + const float STEP = DECEL * delta_time; + auto decay = [STEP](float& v) { + if (std::fabs(v) <= STEP) { + v = 0.0F; + } else { + v -= std::copysign(STEP, v); + } + }; + decay(intended_vx_); + decay(intended_vy_); + if (intended_vx_ == 0.0F && intended_vy_ == 0.0F) { + state_ = State::GROUNDED; + } + input_vx_ = 0.0F; + input_vy_ = 0.0F; + setVelX(intended_vx_); + setVelY(intended_vy_); + return; + } + // Via `InputState` en lloc de `SDL_GetKeyboardState` directe: quan el // service menu (o qualsevol UI modal) està obert, el wrapper retorna // un buffer tot-a-zero perquè el jugador no es mogui mentre l'usuari @@ -379,9 +520,246 @@ void Player::tick(float delta_time) { // canvia el facing (la darrera orientació es preserva), i els girs // només disparen amb intenció explícita del jugador. updateFacingAndTurn(delta_time, input_vx_, input_vy_); + + // 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 + // marcat aquest frame, ho fa el resolver entre tick i applyMovement); + // ho gestionem deixant que el resolver vinga, i si en el moment del + // despegue (consumeJumpEdge → startJump) ja estem AIRBORNE, l'altre + // braç del switch no canvia res. + if (consumeJumpEdge() && jump_config.impulse_px_per_s > 0.0F) { + startJump(); + } +} + +auto Player::consumeJumpEdge() -> bool { + // Edge-detection independent per teclat i mando: si qualsevol dels + // dos passa de no-premut a premut aquest frame, comptem com a salt. + // Sense detecció de flanc, mantenir la tecla provocaria saltar + // immediatament en aterrar. + const bool* keys = InputState::getKeyboardState(); + const bool KEY_NOW = keys[controls_.jump]; + bool edge = (KEY_NOW && !jump_held_keyboard_); + jump_held_keyboard_ = KEY_NOW; + + SDL_Gamepad* pad = GamepadManager::get().getForPlayer(id_); + const bool PAD_NOW = (pad != nullptr) && InputState::getGamepadButton(pad, SDL_GAMEPAD_BUTTON_SOUTH); + if (PAD_NOW && !jump_held_gamepad_) { edge = true; } + jump_held_gamepad_ = PAD_NOW; + + return edge; +} + +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), + // així que la regla és: només GROUNDED arrenca salts. El push viu + // entre `tick` i `applyMovement`, així que si el resolver acaba + // empentant en mig d'un frame on intentàvem saltar, el flanc es perd + // — comportament acceptable: l'empenta guanya al toc curt. + if (state_ != State::GROUNDED) { return; } + + // Un gir de 180° en curs es compromet immediatament: si l'usuari + // ha disparat un giro (oest→est, p.ex.) i salta abans que s'esgote + // el timer, l'animació del salt ha de mostrar la direcció final + // (jump lateral), no la intermèdia (jump_down). Aplem el pending + // ara mateix i resetem l'estat del gir. Si no hi havia gir en + // curs, és no-op. + if (turning_remaining_ms_ > 0.0F) { + applyCardinal(pending_cardinal_); + turning_remaining_ms_ = 0.0F; + pending_cardinal_ = Cardinal::NONE; + intermediate_cardinal_ = Cardinal::NONE; + } + + state_ = State::AIRBORNE; + jump_z_ = 0.0F; + jump_vz_ = jump_config.impulse_px_per_s; + if (!jump_config.sound_key.empty() && Audio::get() != nullptr) { + Audio::get()->playSound(jump_config.sound_key, Audio::Group::GAME); + } + // Avisem al bus perquè el GameScene puga spawnejar el polvet de + // despegue al peu del jugador. + if (bus_ != nullptr) { + bus_->publish(Event{ + .name = "player_jumped", + .payload = WorldPos{.x = getPosX() + (static_cast(sprite_w) * 0.5F), .y = getGroundFeetY()}, + .player_id = id_, + }); + } + // Prou: la velocitat horitzontal del salt és la `intended_v*` actual + // (input del frame). El facing també queda com està. Ja no es + // tocaran fins a aterrar. +} + +auto Player::deltasFor(Cardinal c) -> std::pair { + switch (c) { + case Cardinal::EAST: + return {1.0F, 0.0F}; + case Cardinal::WEST: + return {-1.0F, 0.0F}; + case Cardinal::NORTH: + return {0.0F, -1.0F}; + case Cardinal::SOUTH: + return {0.0F, 1.0F}; + case Cardinal::NONE: + break; + } + return {0.0F, 0.0F}; +} + +auto Player::reachInDirection(float dx, float dy, int max_px) const -> int { + const auto BASE = getBaseBox(); + for (int i = 1; i <= max_px; ++i) { + SDL_FRect probe = BASE; + probe.x += static_cast(i) * dx; + probe.y += static_cast(i) * dy; + if (map_.collides(probe) || !map_.inside_playfield(probe)) { return i - 1; } + } + return max_px; +} + +auto Player::chooseHurtDirection(Cardinal preferred) const -> Cardinal { + constexpr int MAX_PX = 32; // 2 tiles, prou per a la cerca; la inèrcia limita el recorregut real. + + // Recorregut natural del HURT (distància que recórreria sota + // decaiment lineal pur). Si la direcció preferida cabe sense topar + // abans d'aquesta distància, l'usem; d'altra manera, busquem la + // millor. + const auto REQUIRED = static_cast( + 0.5F * hurt_config.initial_speed_px_per_s * (static_cast(hurt_config.brake_time_ms) / 1000.0F)); + + if (preferred != Cardinal::NONE) { + const auto [DX, DY] = deltasFor(preferred); + if (reachInDirection(DX, DY, MAX_PX) >= REQUIRED) { return preferred; } + } + + constexpr std::array ALL{Cardinal::EAST, Cardinal::WEST, Cardinal::NORTH, Cardinal::SOUTH}; + int best_reach = -1; + Cardinal best_dir = Cardinal::EAST; + for (Cardinal c : ALL) { + const auto [DX, DY] = deltasFor(c); + const int R = reachInDirection(DX, DY, MAX_PX); + if (R > best_reach) { + best_reach = R; + best_dir = c; + } + } + return best_dir; +} + +void Player::enterHurt(Cardinal preferred) { + // Si la mecànica no està configurada (speed = 0), no fem res — el + // comportament queda equivalent al d'abans, sense empenta. + if (hurt_config.initial_speed_px_per_s <= 0.0F || hurt_config.brake_time_ms <= 0) { return; } + + const Cardinal CHOSEN = chooseHurtDirection(preferred); + + // Activem l'estat i fixem la velocitat segons la direcció triada. + state_ = State::HURT; + applyCardinal(CHOSEN); + last_input_cardinal_ = CHOSEN; + turning_remaining_ms_ = 0.0F; + pending_cardinal_ = Cardinal::NONE; + intermediate_cardinal_ = Cardinal::NONE; + being_pushed_ = false; + const float V = hurt_config.initial_speed_px_per_s; + const auto [DX, DY] = deltasFor(CHOSEN); + intended_vx_ = DX * V; + intended_vy_ = DY * V; + input_vx_ = 0.0F; + input_vy_ = 0.0F; + motion_time_ms_ = 0.0F; +} + +auto Player::integrateJump(float delta_time) -> bool { + if (state_ != State::AIRBORNE) { return false; } + jump_vz_ -= jump_config.gravity_px_per_s2 * delta_time; + jump_z_ += jump_vz_ * delta_time; + if (jump_z_ <= 0.0F) { + jump_z_ = 0.0F; + jump_vz_ = 0.0F; + state_ = State::LANDING_STUN; + landing_stun_remaining_ms_ = static_cast(jump_config.landing_stun_ms); + if (!jump_config.land_sound_key.empty() && Audio::get() != nullptr) { + Audio::get()->playSound(jump_config.land_sound_key, Audio::Group::GAME); + } + if (bus_ != nullptr) { + bus_->publish(Event{ + .name = "player_landed", + .payload = WorldPos{.x = getPosX() + (static_cast(sprite_w) * 0.5F), .y = getGroundFeetY()}, + .player_id = id_, + }); + } + return true; + } + return false; +} + +void Player::resolveLandingOverlap() { + // Quan el saltador (this) aterra al damunt d'un altre jugador, el + // saltador NO es desplaça — es queda al lloc en LANDING_STUN. En + // canvi, qualsevol jugador amb el qual solape entra en HURT i + // surt projectat. Així el solapament inicial es resol per moviment + // del solapat, no per desplaçament forçat al lloc. + if (all_players_ == nullptr) { return; } + for (Player* other : *all_players_) { + if (other == nullptr || other == this) { continue; } + // Test amb la hit-box (8×8 de contacte precís), no la base-box. + // Coherent amb la resta de col·lisions actor-actor del joc: + // momies, fuetades futures, resolver PvP… tots usen hit-box. Un + // aterratge "rozant" (cossos solapats però els punts de contacte + // separats) no dispara HURT — el frame següent els dos estan a + // terra i la col·lisió normal els gestiona. + const SDL_FRect MY_HIT = getHitBox(); + const SDL_FRect OT_HIT = other->getHitBox(); + if (!SDL_HasRectIntersectionFloat(&MY_HIT, &OT_HIT)) { continue; } + + // Direcció preferida del HURT: cap on l'altre està respecte a + // nosaltres (cardinal dominant del vector centre→centre this → + // other). Si caem a la dreta del seu cos, ell està a l'esquerra + // nostra → preferred = WEST → surt cap a l'esquerra. Si caem + // exactament al damunt (centres alineats), preferred queda + // NONE i `enterHurt` busca la millor direcció lliure. + const float MCX = MY_HIT.x + (MY_HIT.w * 0.5F); + const float MCY = MY_HIT.y + (MY_HIT.h * 0.5F); + const float OCX = OT_HIT.x + (OT_HIT.w * 0.5F); + const float OCY = OT_HIT.y + (OT_HIT.h * 0.5F); + const float DX = OCX - MCX; + const float DY = OCY - MCY; + Cardinal preferred = Cardinal::NONE; + if (std::fabs(DX) >= std::fabs(DY) && DX != 0.0F) { + preferred = (DX > 0.0F) ? Cardinal::EAST : Cardinal::WEST; + } else if (DY != 0.0F) { + preferred = (DY > 0.0F) ? Cardinal::SOUTH : Cardinal::NORTH; + } + + other->enterHurt(preferred); + } } void Player::applyMovement(float delta_time) { + // Integració del salt: si estem AIRBORNE, avança jump_z_/jump_vz_. + // El facing i intended_v* es mantenen del moment del despegue (no es + // recalculen). Si toquem el sòl aquest frame, transicionem a + // LANDING_STUN i resolem el possible solapament amb un altre + // jugador que estigués sota — apartem-lo (o, si no es pot, ens + // apartem nosaltres). + if (integrateJump(delta_time)) { + resolveLandingOverlap(); + } + + if (state_ == State::HURT) { + applyHurtMovement(delta_time); + return; + } + + if (state_ == State::LANDING_STUN) { + applyLandingStunMovement(delta_time); + 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. @@ -390,9 +768,12 @@ void Player::applyMovement(float delta_time) { // Snap al passadís: si només hi ha moviment horitzontal (o vertical) i // la caixa està prou prop d'una lane perpendicular (≤ mitja caixa), la - // Y (o X) es corregeix gradualment per enfilar el passadís. - const bool ONLY_H = (VX != 0.0F) && (VY == 0.0F); - const bool ONLY_V = (VY != 0.0F) && (VX == 0.0F); + // Y (o X) es corregeix gradualment per enfilar el passadís. En l'aire + // no fem snap: la trajectòria queda fixada al despegue, que el snap + // mid-flight desviaria. + const bool AIRBORNE = (state_ == State::AIRBORNE); + const bool ONLY_H = !AIRBORNE && (VX != 0.0F) && (VY == 0.0F); + const bool ONLY_V = !AIRBORNE && (VY != 0.0F) && (VX == 0.0F); const float FRAME_START_X = getPosX(); const float FRAME_START_Y = getPosY(); @@ -420,31 +801,161 @@ void Player::applyMovement(float delta_time) { const bool MOVED = (getPosX() != FRAME_START_X) || (getPosY() != FRAME_START_Y); // `being_pushed_` força `stand_*`: el jugador es desplaça sense input, // animar-lo com a caminant faria creure que està caminant quan de - // fet és arrossegat per l'altre. + // fet és arrossegat per l'altre. AIRBORNE força `jump_*` (vegeu + // `animationName`), independentment de la velocitat. const bool ANIM_AS_MOVING = MOVED && (turning_remaining_ms_ <= 0.0F) && !being_pushed_; setCurrentAnimation(animationName(ANIM_AS_MOVING)); - // Detecció de canvi de tile. Es calcula *després* del clamp i de les - // col·lisions perquè la posició aquí ja és la definitiva del frame; - // així el PaintLayer no rep falsos positius mentre el Player rellisca - // contra una paret. Si el tile ha canviat, publiquem `on_tile_entered` - // al bus amb l'índex de pintat d'aquest jugador; el PaintLayer filtra - // pintabilitat i re-publica `tile_painted` als sarcòfags. - if (bus_ != nullptr) { - const auto [COL, ROW] = getCurrentTile(); - if (COL != last_tile_col_ || ROW != last_tile_row_) { - last_tile_col_ = COL; - last_tile_row_ = ROW; - bus_->publish(Event{ - .name = "on_tile_entered", - .payload = PaintedTile{.col = COL, .row = ROW, .tile_index = paint_tile_index_}, - .player_id = id_, - }); - } + // Detecció de canvi de tile. Només publiquem en GROUNDED — en + // AIRBORNE el jugador no trepitja, i en HURT/LANDING_STUN les + // pròpies branques del switch ja crida la helper si toca. + if (state_ == State::GROUNDED) { publishTileEnteredIfChanged(); } +} + +void Player::applyHurtMovement(float delta_time) { + // En HURT el jugador es desplaça segons `intended_v*` (escrit a + // `enterHurt` i decremental a `tick`). Si `tryMove` no avança + // perquè topa amb un mur, el parem en sec i tornem a GROUNDED — + // honra la regla "salvo col·lisió, llavors para". + animate(delta_time); + const float FRAME_START_X = getPosX(); + const float FRAME_START_Y = getPosY(); + tryMove(intended_vx_ * delta_time, intended_vy_ * delta_time, [this](const SDL_FRect& box) { + return collidesWithWorld(box); + }); + clampToPlayfield(); + const bool MOVED = (getPosX() != FRAME_START_X) || (getPosY() != FRAME_START_Y); + const bool WANTED_TO_MOVE = (intended_vx_ != 0.0F) || (intended_vy_ != 0.0F); + if (!MOVED && WANTED_TO_MOVE) { + // Bloquejat per un mur → parem en sec i recuperem el control. + intended_vx_ = 0.0F; + intended_vy_ = 0.0F; + state_ = State::GROUNDED; } + setCurrentAnimation(animationName(true)); + publishTileEnteredIfChanged(); +} + +void Player::applyLandingStunMovement(float delta_time) { + // En LANDING_STUN no movem (la velocitat ja és 0). Animem per + // mantenir el frame del stun i emetem la tile d'aterratge perquè + // es pinte. + animate(delta_time); + setCurrentAnimation(animationName(false)); + publishTileEnteredIfChanged(); +} + +void Player::publishTileEnteredIfChanged() { + if (bus_ == nullptr) { return; } + const auto [COL, ROW] = getCurrentTile(); + if (COL == last_tile_col_ && ROW == last_tile_row_) { return; } + last_tile_col_ = COL; + last_tile_row_ = ROW; + bus_->publish(Event{ + .name = "on_tile_entered", + .payload = PaintedTile{.col = COL, .row = ROW, .tile_index = paint_tile_index_}, + .player_id = id_, + }); +} + +void Player::render() { + // Mentre el jugador està enlaire, el sprite es desplaça visualment + // cap amunt `jump_z_` píxels. Modifiquem `pos_.y` (la posició entera + // de render) abans de cridar el render base, i la restaurem després + // perquè l'estat seguent del frame ho recalcule des de `y_` (la font + // de veritat float). El sòl sota el jugador no es pinta des d'ací: + // això ho fa el GameScene en una passada de sombres a banda. + if (jump_z_ <= 0.0F) { + AnimatedSprite::render(); + return; + } + const float SAVED_Y = pos_.y; + pos_.y = SAVED_Y - jump_z_; + AnimatedSprite::render(); + pos_.y = SAVED_Y; +} + +void Player::renderShadow() const { + // Sense salt actiu, no hi ha sombra: això simplifica els callsites, + // que poden cridar `renderShadow` per a tots els jugadors sense + // discriminar. + if (!isAirborne()) { return; } + + // Sombra centrada al sprite, recolzada a la línia dels peus reals + // (ignorant `jump_z_`). L'amplada es redueix proporcional a l'altura + // del salt — al pic queda més estreta, donant la sensació de + // "elevació" sense necessitat de coordenades 3D reals. Si el + // jump_config té un divisor zero (cas degenerat), només dibuixem + // la sombra a mida plena per evitar nan. + const auto PEAK = (jump_config.gravity_px_per_s2 > 0.0F) + ? (jump_config.impulse_px_per_s * jump_config.impulse_px_per_s) / (2.0F * jump_config.gravity_px_per_s2) + : 0.0F; + const float NORM_Z = (PEAK > 0.0F) ? std::clamp(jump_z_ / PEAK, 0.0F, 1.0F) : 0.0F; + constexpr float MAX_SHRINK = 0.4F; // Fins a un 40% més estreta al pic. + const float SHRINK = 1.0F - (NORM_Z * MAX_SHRINK); + + constexpr int SHADOW_H = 3; + const auto SHADOW_W = static_cast(static_cast(base_size) * SHRINK); + const float CX = getPosX() + (static_cast(sprite_w) * 0.5F); + const float GROUND_Y = getGroundFeetY(); + const auto SX = static_cast(CX - (static_cast(SHADOW_W) * 0.5F)); + // Pugem la sombra `shadow_y_offset` px per compensar la franja + // transparent que el sprite del jugador té sota els peus reals + // (perquè la silueta camine pel mig del passadís en falsa + // perspectiva). Sense aquest ajust, la sombra apareix per sota + // del passadís. + const auto SY = static_cast(GROUND_Y) - SHADOW_H - jump_config.shadow_y_offset; + + // ARGB negre amb alpha ~40%. `blendRect` mescla sobre el canvas; així + // la sombra fosqueja el sòl (pintat o no) sense tapar-lo del tot. + constexpr std::uint32_t SHADOW_ARGB = 0x00000000U; + constexpr std::uint8_t SHADOW_ALPHA = 100; + Screen::get().blendRect(SX, SY, SHADOW_W, SHADOW_H, SHADOW_ARGB, SHADOW_ALPHA); } auto Player::animationName(bool moving) const -> const char* { + // En l'aire forcem la variant `jump_*` (per direcció), independent de + // si hi ha velocitat horitzontal: per al jugador, l'estat visual del + // salt és per direcció, no per velocitat. + if (state_ == State::AIRBORNE) { + switch (facing_) { + case Facing::UP: + return "jump_up"; + case Facing::DOWN: + return "jump_down"; + case Facing::SIDE: + break; + } + return "jump"; + } + // En LANDING_STUN, `stun_*` (per direcció): mostrem el frame + // d'aterratge fins que s'esgota el timer. La direcció ve fixada del + // moment del despegue (no es canvia mentre estem en l'aire). + if (state_ == State::LANDING_STUN) { + switch (facing_) { + case Facing::UP: + return "stun_up"; + case Facing::DOWN: + return "stun_down"; + case Facing::SIDE: + break; + } + return "stun"; + } + // En HURT, `hurt_*` (per direcció): el jugador llisca sense + // control en la direcció triada per `enterHurt`. Mateixa estructura + // que walk per facilitat (ara mateix els frames són clons). + if (state_ == State::HURT) { + switch (facing_) { + case Facing::UP: + return "hurt_up"; + case Facing::DOWN: + return "hurt_down"; + case Facing::SIDE: + break; + } + return "hurt"; + } switch (facing_) { case Facing::UP: return moving ? "walk_up" : "stand_up"; diff --git a/source/game/entities/player.hpp b/source/game/entities/player.hpp index e4b95c9..b5ebc55 100644 --- a/source/game/entities/player.hpp +++ b/source/game/entities/player.hpp @@ -8,6 +8,7 @@ #include #include "core/rendering/sprite/animated_sprite.hpp" +#include "external/fkyaml_node.hpp" #include "game/map/map.hpp" class EventBus; @@ -22,6 +23,27 @@ class Player : public AnimatedSprite { SDL_Scancode down; SDL_Scancode left; SDL_Scancode right; + SDL_Scancode jump; + }; + + // Estat d'alt nivell del jugador. Ortogonal a `Facing`/`Cardinal` + // (direcció) i a `being_pushed_` (flag d'animació). Determina què pot + // fer el jugador aquest frame: + // GROUNDED → input normal, pot saltar. + // AIRBORNE → física del salt; ignora input direccional, sense + // col·lisió amb caminants però sí amb altres + // saltadors a la mateixa franja d'alçada (rebot). + // LANDING_STUN → 200ms post-aterratge, sense input ni moviment. + // HURT → projectat sense control (cau d'algú al damunt o, + // en el futur, fuetada): velocitat inicial alta + // que decau per inèrcia fins a parar (~2 tiles). + // No col·lisiona amb cap altre jugador. Quan + // s'atura, torna a GROUNDED. + enum class State : std::uint8_t { + GROUNDED, + AIRBORNE, + LANDING_STUN, + HURT, }; // Geometria carregada des del YAML del player (p.ex. `data/player/sam.yaml`). @@ -51,6 +73,12 @@ class Player : public AnimatedSprite { inline static int hit_x_offset = 0; inline static int hit_y_offset = 0; + // Alçada del cos del jugador en píxels (camp `collision.height` del + // YAML, mateixa convenció que entitats com mummy o rolling_rock). + // L'usen els filtres de col·lisió per altura: si un altre actor ens + // creua mentre `jump_z_` és superior a aquest valor, no col·lisionem. + inline static int body_height = 0; + // Ha de cridar-se abans d'instanciar Player; GameScene ho fa al ctor. static void loadFrom(const std::string& yaml_path); @@ -127,11 +155,39 @@ class Player : public AnimatedSprite { // Y de la base del jugador (cara inferior de la caixa de colisió). Usada // per al Z-order contra els sarcs: peus per sobre d'un sarc → darrere; - // per sota → davant. + // per sota → davant. NO restem `jump_z_`: el saltador no canvia de + // tile lògica, només s'eleva visualment, i el sorting ha de respectar + // la posició al món (un sarc al nord segueix darrere encara que el + // sprite del jugador estigui amunt). Veure també `getGroundFeetY`, + // que coincideix amb aquest mètode i s'usa específicament per + // marcar la posició de la sombra a nivell de sòl. [[nodiscard]] auto getFeetY() const -> float { return getPosY() + static_cast(collision_y_offset + collision_size); } + // Alies semàntic: els peus del jugador al sòl. Coincideix amb + // `getFeetY` i s'usa al callsite del render de la sombra per fer + // explícit que la sombra es pinta al pla del sòl, no a la posició + // visual del sprite. + [[nodiscard]] auto getGroundFeetY() const -> float { return getFeetY(); } + + // Estat actual de la màquina d'estats. L'usa el render (animació + // jump_*), el resolver de col·lisions (filtre per altura) i el + // GameScene (per saber si renderitzar la sombra). + [[nodiscard]] auto getState() const -> State { return state_; } + [[nodiscard]] auto isAirborne() const -> bool { return state_ == State::AIRBORNE; } + [[nodiscard]] auto isHurt() const -> bool { return state_ == State::HURT; } + // Tangible per a contactes jugador↔jugador: només els estats a terra + // amb cos sòlid. AIRBORNE i HURT són intangibles (es travessen). + [[nodiscard]] auto isPvpTangible() const -> bool { + return state_ == State::GROUNDED || state_ == State::LANDING_STUN; + } + + // Alçada actual del salt (píxels visuals per damunt del sòl). + // 0 quan està a terra. L'usa el render i el sistema de col·lisions + // per altura. + [[nodiscard]] auto getJumpZ() const -> float { return jump_z_; } + // Injecta el bus del nivell. El Player publica `on_tile_entered` cada // vegada que el centre de la seua caixa de col·lisió entra en una // cel·la nova del tilemap, amb payload `PaintedTile{col,row,paint_tile_index_}`. @@ -166,6 +222,18 @@ class Player : public AnimatedSprite { // bloquejar el moviment). [[nodiscard]] auto collisionBox() const -> SDL_FRect override { return getBaseBox(); } + // Render del jugador. Mentre està en l'aire, el sprite es desplaça + // visualment cap amunt `jump_z_` píxels (el cos del jugador, no la + // sombra al sòl, que la pinta el GameScene a banda). La col·lisió i + // la posició al món no s'alteren. + void render() override; + + // Pinta una sombra translúcida sota el jugador a nivell de sòl + // (sense aplicar `jump_z_`). El GameScene la crida en una passada + // pre-actors, així queda per sota dels actors i del propi jugador. + // No-op si el jugador no està en l'aire. + void renderShadow() const; + private: // Alias privat històric. Mantingut perquè viu dins de l'implementació // (snapToLane, clamp). Els consumidors externs usen `getBaseBox()`. @@ -308,6 +376,116 @@ class Player : public AnimatedSprite { // per defecte de TurnConfig encara no són visibles per al compilador. static TurnConfig turn_config; + // Configuració del salt llegida del bloc `jump:` del YAML del player. + // Si `impulse_px_per_s == 0` (o el bloc no existeix) la mecànica del + // salt queda desactivada — el botó no té efecte. + struct JumpConfig { + float impulse_px_per_s{0.0F}; // Velocitat vertical inicial. + float gravity_px_per_s2{0.0F}; // Acceleració descendent constant. + int landing_stun_ms{0}; // Stun en aterrar (no es mou ni salta). + int shadow_y_offset{0}; // Px que puja la sombra (compensa transparències al peu del sprite). + std::string sound_key; // Clau al Resource::Cache del so de despegue (buit = silenci). + std::string land_sound_key; // Clau del so d'aterratge (buit = silenci). + }; + static JumpConfig jump_config; + + // Llig el bloc `collision:` del YAML i n'omple les caixes (size/base/ + // hit) + `body_height`. Llança si el bloc no existeix. Membre estàtic + // per accedir als estàtics privats sense exposar-los. + static void readCollisionBlock(const fkyaml::node& yaml, const std::string& yaml_path); + + // Llig el bloc `jump:` del YAML i n'omple `jump_config`. Si el bloc + // manca, deixa la mecànica del salt desactivada. + static void readJumpBlock(const fkyaml::node& yaml); + + // Configuració de l'estat HURT (estat post-impacte). Si + // `initial_speed_px_per_s == 0` el jugador entra i surt + // instantàniament (cap moviment). + struct HurtConfig { + float initial_speed_px_per_s{0.0F}; // Velocitat amb què surt projectat (px/s). + int brake_time_ms{0}; // Temps en que la velocitat decau a 0. + }; + static HurtConfig hurt_config; + + // Llig el bloc `hurt:` del YAML i n'omple `hurt_config`. + static void readHurtBlock(const fkyaml::node& yaml); + + // Activa l'estat HURT: el jugador queda projectat amb velocitat + // inicial `hurt_config.initial_speed_px_per_s`. La direcció es tria + // així: + // - Si `preferred != NONE` i hi cap el recorregut natural complet + // (sense topar amb mur abans d'esgotar la inèrcia), s'usa + // `preferred`. + // - Altrament (o si `preferred == NONE`), prova els 4 cardinals + // 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. + void enterHurt(Cardinal preferred = Cardinal::NONE); + + // 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. + [[nodiscard]] auto chooseHurtDirection(Cardinal preferred) const -> Cardinal; + + // Recorregut lliure (en píxels, fins a `max_px`) en la direcció + // (dx, dy) abans de topar amb un mur o eixir del playfield. La + // base-box es projecta `i` píxels i es comprova; retorna `i-1` al + // primer xoc. + [[nodiscard]] auto reachInDirection(float dx, float dy, int max_px) const -> int; + + // Vectors unitaris dels 4 cardinals. `NONE` retorna (0, 0). + [[nodiscard]] static auto deltasFor(Cardinal c) -> std::pair; + + // Estat de la màquina d'estats. Mutat per `tick`/`applyMovement`. Per + // defecte GROUNDED al spawn. + State state_{State::GROUNDED}; + + // Física del salt (només té sentit en AIRBORNE; ambdós a 0 a terra). + // jump_z_ → altura visual del jugador per damunt del sòl, en píxels. + // jump_vz_ → velocitat vertical actual (positiva = puja, negativa = cau). + float jump_z_{0.0F}; + float jump_vz_{0.0F}; + + // Temps restant de stun d'aterratge (ms). Mentre > 0, l'estat és + // LANDING_STUN i tot l'input queda bloquejat. + float landing_stun_remaining_ms_{0.0F}; + + // Estat anterior del botó de salt per fer detecció de flanc (saltem + // només a la transició no-premut → premut, no mentre es manté). Es + // manté un per teclat i un per mando perquè poden conviure. + bool jump_held_keyboard_{false}; + bool jump_held_gamepad_{false}; + + // 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; + + // Inicia un salt: copia `intended_v*` com a velocitat fixada del salt, + // posa `jump_vz_ = impulse`, transiciona a AIRBORNE. + void startJump(); + + // Integra la física del salt aquest frame i, si toca el sòl, marca el + // canvi a LANDING_STUN. Retorna true si aquest frame ha aterrat (perquè + // `applyMovement` puga disparar la resolució del solapament PvP). + auto integrateJump(float delta_time) -> bool; + + // En aterrar, si la base-box del jugador solapa amb la d'un altre, + // l'altre entra en `HURT` i surt projectat per `enterHurt`. El + // saltador (this) es queda al lloc — el moviment per separar-los + // el fa el HURT en cours, no una empenta forçada. + void resolveLandingOverlap(); + + // Branques de `applyMovement` extretes per acotar la complexitat + // cognitiva. Cada estat especial gestiona el seu propi cicle + // (animació, tryMove o no, publicació de tile). + void applyHurtMovement(float delta_time); + void applyLandingStunMovement(float delta_time); + + // Publica `on_tile_entered` al bus si el tile ha canviat respecte a + // l'últim publicat. Comú a totes les branques de `applyMovement` que + // pinten el sòl (GROUNDED, HURT, LANDING_STUN; en AIRBORNE no). + void publishTileEnteredIfChanged(); + // Estat del gir en curs. Mentre `turning_remaining_ms_ > 0`, el facing/ // flip es manté forçat a `intermediate_cardinal_` i l'animació es força // a `stand` encara que el moviment continuï. En esgotar-se el timer, diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index 9f8362e..82c21c8 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -26,7 +26,9 @@ #include "core/system/event_buffer.hpp" #include "core/ui/ui_config.hpp" #include "external/fkyaml_node.hpp" +#include "game/entities/concrete/dust.hpp" #include "game/entities/entity.hpp" +#include "game/entities/entity_loader.hpp" #include "game/entities/event_bus.hpp" #include "game/entities/player.hpp" #include "game/map/map.hpp" @@ -103,11 +105,27 @@ namespace { auto* b = ps[1]; if (a == nullptr || b == nullptr) { return; } + const bool A_AIR = a->isAirborne(); + const bool B_AIR = b->isAirborne(); + + // HURT a un costat o l'altre: el resolver no s'activa — el HURT + // surt projectat sense control i els dos es travessen. Coincideix + // amb la regla d'`Player::collidesWithWorld`. + if (a->isHurt() || b->isHurt()) { return; } + + // Aire ↔ tierra: el saltador es travessa net del caminant. Sense + // resolver, sense head-on, sense push. Els dos continuen amb la + // velocitat que portaven. Important: això NO és el filtre per + // altura del cos — és binari. Coincideix amb la regla d'`Player:: + // collidesWithWorld`: durant un salt no hi ha mai col·lisió amb + // un caminant, ni baix ni alt. + if (A_AIR != B_AIR) { return; } + // Calculem l'overlap previst en l'espai de la hit-box petita: // els jugadors han de poder acostar-se molt (caixes de moviment // 16×16 quasi tocant) sense disparar el resolver; només quan el // contacte real (hit-box 8×8) s'activaria, neutralitzem o apliquem - // push. + // push (a terra) o invertim velocitats (en l'aire). const SDL_FRect BOX_A = a->getHitBox(); const SDL_FRect BOX_B = b->getHitBox(); const SDL_FRect NEXT_A{ @@ -124,6 +142,32 @@ namespace { }; if (!rectsOverlap(NEXT_A, NEXT_B)) { return; } + // Tots dos en l'aire (A_AIR == B_AIR == true aquí, ja descartat + // l'altre cas a dalt): rebot mid-aire si els rangs verticals + // [z, z+body_height] solapen. Si la diferència d'alçades és + // massa gran, ja no hi ha contacte real (un està molt més amunt + // que l'altre) i no fem res. Si solapen, invertim ambdues + // velocitats horitzontals i els deixem rebotar fins a aterrar + // — els dos cauen en stun, com a càstig per abusar del salt. + if (A_AIR) { + // cppcheck-suppress knownConditionTrueFalse + const auto BODY_H = static_cast(Player::body_height); + // cppcheck-suppress knownConditionTrueFalse + if (BODY_H <= 0.0F) { return; } + if (std::fabs(a->getJumpZ() - b->getJumpZ()) >= BODY_H) { return; } + // Saltadors estàtics horitzontalment (cas degenerat: tots dos + // jumpejant verticals al mateix x): no hi ha velocitat a + // invertir, deixem-los — el predictiu no tornarà a disparar + // perquè en el següent frame ja no hi haurà velocitat + // d'acostament. + const float REL_VX = a->getIntendedVX() - b->getIntendedVX(); + const float REL_VY = a->getIntendedVY() - b->getIntendedVY(); + if (REL_VX == 0.0F && REL_VY == 0.0F) { return; } + a->setIntendedVelocity(-a->getIntendedVX(), -a->getIntendedVY()); + b->setIntendedVelocity(-b->getIntendedVX(), -b->getIntendedVY()); + return; + } + const bool MOVING_A = (a->getInputVX() != 0.0F) || (a->getInputVY() != 0.0F); const bool MOVING_B = (b->getInputVX() != 0.0F) || (b->getInputVY() != 0.0F); @@ -231,6 +275,21 @@ GameScene::GameScene(Config config) << " dmg=" << (dmg != nullptr ? *dmg : 0) << '\n'; }); + // Polvet: precarreguem el surface del Dust una sola vegada perquè + // els spawns runtime de l'entitat no facin disc al primer salt. + // També ens subscrivim a `player_jumped` i `player_landed` per + // spawnejar el polvet de despegue / aterratge a la posició dels + // peus que el Player ens dóna al payload. + Resource::Cache::get().loadSurface("dust.gif", "data/entities/dust/dust.gif"); + entity_manager_.getBus().subscribe("player_jumped", [this](const Event& ev) { + const auto* pos = std::get_if(&ev.payload); + if (pos != nullptr) { spawnDust(pos->x, pos->y, false); } + }); + entity_manager_.getBus().subscribe("player_landed", [this](const Event& ev) { + const auto* pos = std::get_if(&ev.payload); + if (pos != nullptr) { spawnDust(pos->x, pos->y, true); } + }); + // 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. @@ -239,12 +298,14 @@ GameScene::GameScene(Config config) .down = Defaults::KeysGame::DOWN, .left = Defaults::KeysGame::LEFT, .right = Defaults::KeysGame::RIGHT, + .jump = Defaults::KeysGame::JUMP, }; const Player::Controls CONTROLS_P2{ .up = Defaults::KeysGameP2::UP, .down = Defaults::KeysGameP2::DOWN, .left = Defaults::KeysGameP2::LEFT, .right = Defaults::KeysGameP2::RIGHT, + .jump = Defaults::KeysGameP2::JUMP, }; // Spawn centrat al passadís exterior superior: la caixa de col·lisió // queda dins del passadís vertical del mig (entre columnes 1 i 2) i @@ -338,6 +399,30 @@ GameScene::GameScene(Config config) GameScene::~GameScene() = default; +void GameScene::spawnDust(float feet_x_center, float feet_y, bool is_landing) { + // Mida del sprite de Dust llegida del seu YAML. Hardcoded per + // simplificar — si canvia, només cal tocar dust.yaml + aquesta + // constant. La caixa de render es centra horitzontalment al peu i + // s'alinea al sòl amb un pixel de marge cap amunt perquè la sombra + // quede pintada per davant del polvet i el polvet, en l'y-sort, + // entre per davant del jugador (mateixa lògica que la sombra). + constexpr float DUST_W = 40.0F; + constexpr float DUST_H = 16.0F; + auto dust = std::make_unique(EntityLoader::loadTemplate("dust")); + dust->setVariant(is_landing ? Dust::Variant::LAND : Dust::Variant::JUMP); + dust->setSurface(Resource::Cache::get().getSurface("dust.gif")); + // Recolzat al sòl, amb la base del rect 1 px per sobre del peu del + // jugador → en l'y-sort surt amb feet_y un pèl menor i, per + // estabilitat de stable_sort, queda darrere del jugador. + dust->setPixelRect({ + .x = feet_x_center - (DUST_W * 0.5F), + .y = feet_y - DUST_H - 1.0F, + .w = DUST_W, + .h = DUST_H, + }); + entity_manager_.add(std::move(dust)); +} + void GameScene::handlePauseInput() { #ifdef _DEBUG // Mutex amb l'editor: si està actiu, ignorem la tecla de pausa. @@ -519,6 +604,12 @@ void GameScene::render() { // explícita: l'editor edita el tilemap, no la capa de pintat). if (!SKIP_ACTORS && paint_layer_) { paint_layer_->render(); } if (!SKIP_ACTORS) { + // Passada de sombres dels jugadors saltant. Va per davant del + // pintat (al damunt) però per darrere de TOTS els actors — + // entitats i jugadors, inclòs el saltador mateix— així la sombra + // queda al sòl sense tapar res. `renderShadow` és no-op si el + // jugador està a terra, així que no cal filtrar al callsite. + for (const auto& p : players_) { p->renderShadow(); } // Z-order interleaving entitats ↔ players per `getFeetY()` ascendent: // els actors amb els peus més amunt queden dibuixats primer (al fons), // els més avall al davant. Així un sarc situat per darrere d'un player diff --git a/source/game/scenes/game_scene.hpp b/source/game/scenes/game_scene.hpp index a82db53..f982164 100644 --- a/source/game/scenes/game_scene.hpp +++ b/source/game/scenes/game_scene.hpp @@ -56,6 +56,12 @@ class GameScene : public Scene { // (no escalat) perquè la transició viu en temps real. void updatePauseTransition(int delta_ms); + // Spawna un Dust transitori centrat a (`feet_x_center`, `feet_y`). + // Cridat per la subscripció del bus a `player_jumped` / + // `player_landed` per pintar el polvet de despegue / aterratge. + // `is_landing` selecciona la variant de l'animació al `Dust`. + void spawnDust(float feet_x_center, float feet_y, bool is_landing); + Config config_; Tilemap tilemap_; diff --git a/source/utils/defaults.hpp b/source/utils/defaults.hpp index dbef97f..bedd74f 100644 --- a/source/utils/defaults.hpp +++ b/source/utils/defaults.hpp @@ -77,6 +77,7 @@ namespace Defaults { constexpr SDL_Scancode DOWN = SDL_SCANCODE_DOWN; constexpr SDL_Scancode LEFT = SDL_SCANCODE_LEFT; constexpr SDL_Scancode RIGHT = SDL_SCANCODE_RIGHT; + constexpr SDL_Scancode JUMP = SDL_SCANCODE_M; constexpr SDL_Scancode EXIT = SDL_SCANCODE_ESCAPE; } // namespace KeysGame @@ -89,6 +90,7 @@ namespace Defaults { constexpr SDL_Scancode DOWN = SDL_SCANCODE_S; constexpr SDL_Scancode LEFT = SDL_SCANCODE_A; constexpr SDL_Scancode RIGHT = SDL_SCANCODE_D; + constexpr SDL_Scancode JUMP = SDL_SCANCODE_V; } // namespace KeysGameP2 namespace Player { diff --git a/source/utils/defines.hpp b/source/utils/defines.hpp index 3449980..2a6ae39 100644 --- a/source/utils/defines.hpp +++ b/source/utils/defines.hpp @@ -3,7 +3,7 @@ namespace Texts { constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte — JailDesigner"; constexpr const char* COPYRIGHT = "© 2026 JailDesigner"; - constexpr const char* VERSION = "0.22.0"; // Single source of truth — Makefile el grep + constexpr const char* VERSION = "0.23.0"; // Single source of truth — Makefile el grep } // namespace Texts namespace GameCanvas {