Merge branch 'feat/player-jump' — sistema complet de salt, HURT i polvet
This commit is contained in:
commit
a5362cf223
28 files changed
+1174
-48
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 306 B |
@@ -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 }
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.1 KiB |
@@ -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
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.1 KiB |
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,57 @@
|
||||
#include "game/entities/concrete/dust.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#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<int> 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<int>());
|
||||
}
|
||||
if (frames.empty()) { frames.push_back(0); }
|
||||
}
|
||||
if (anim.contains("fps")) { fps = anim["fps"].get_value<float>(); }
|
||||
if (anim.contains("loop")) { loop = anim["loop"].get_value<bool>(); }
|
||||
}
|
||||
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>() : 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)
|
||||
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#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_;
|
||||
};
|
||||
@@ -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<AnimationComponent>& { 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> surface_{nullptr};
|
||||
bool collides_{false};
|
||||
bool interactable_{false};
|
||||
int height_{0};
|
||||
bool dead_{false};
|
||||
SDL_FlipMode flip_{SDL_FLIP_NONE};
|
||||
EventBus* bus_{nullptr};
|
||||
};
|
||||
@@ -64,7 +64,16 @@ auto EntityManager::addSarcophagus(std::unique_ptr<Sarcophagus> 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) {
|
||||
|
||||
@@ -63,8 +63,15 @@ void EntityManager::checkPlayerCollisions(const std::vector<Player*>& 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<float>(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<int>();
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -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<std::monostate, int, std::string, Entity*, TilePos, PaintedTile>;
|
||||
using Payload = std::variant<std::monostate, int, std::string, Entity*, TilePos, PaintedTile, WorldPos>;
|
||||
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 {
|
||||
|
||||
+555
-44
@@ -3,12 +3,15 @@
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <stdexcept>
|
||||
#include <utility>
|
||||
|
||||
#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<int>();
|
||||
base_size = c["base_size"].get_value<int>();
|
||||
|
||||
// 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<int>() : 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<std::string>()); }
|
||||
}
|
||||
}
|
||||
|
||||
// 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<int>();
|
||||
base_size = c["base_size"].get_value<int>();
|
||||
|
||||
// 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<int>() : 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<int>() : 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<float>(); }
|
||||
if (j.contains("gravity")) { jump_config.gravity_px_per_s2 = j["gravity"].get_value<float>(); }
|
||||
if (j.contains("landing_stun_ms")) { jump_config.landing_stun_ms = j["landing_stun_ms"].get_value<int>(); }
|
||||
if (j.contains("shadow_y_offset")) { jump_config.shadow_y_offset = j["shadow_y_offset"].get_value<int>(); }
|
||||
// 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<std::string>();
|
||||
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<std::string>();
|
||||
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<float>(); }
|
||||
if (h.contains("brake_time_ms")) { hurt_config.brake_time_ms = h["brake_time_ms"].get_value<int>(); }
|
||||
}
|
||||
|
||||
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<float>(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<float>(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<float>(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<float, float> {
|
||||
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<float>(i) * dx;
|
||||
probe.y += static_cast<float>(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<int>(
|
||||
0.5F * hurt_config.initial_speed_px_per_s * (static_cast<float>(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<Cardinal, 4> 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<float>(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<float>(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<int>(static_cast<float>(base_size) * SHRINK);
|
||||
const float CX = getPosX() + (static_cast<float>(sprite_w) * 0.5F);
|
||||
const float GROUND_Y = getGroundFeetY();
|
||||
const auto SX = static_cast<int>(CX - (static_cast<float>(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<int>(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";
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include <vector>
|
||||
|
||||
#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<float>(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<float, float>;
|
||||
|
||||
// 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,
|
||||
|
||||
@@ -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<float>(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<WorldPos>(&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<WorldPos>(&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<Dust>(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
|
||||
|
||||
@@ -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_;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user