Files
aee-arcade/source/game/entities/player.hpp
T

949 lines
49 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#pragma once
#include <SDL3/SDL.h>
#include <cstdint>
#include <string>
#include <utility>
#include <vector>
#include "core/rendering/sprite/animated_sprite.hpp"
#include "core/rendering/surface.hpp"
#include "external/fkyaml_node.hpp"
#include "game/map/map.hpp"
class EventBus;
class EntityManager;
class Player : public AnimatedSprite {
public:
// Mapeig de tecles per al moviment del jugador. Cada jugador local en pot
// tenir unes diferents; així podem afegir-ne més sense tocar el codi del
// Player, només instanciant amb Controls distints.
struct Controls {
SDL_Scancode up;
SDL_Scancode down;
SDL_Scancode left;
SDL_Scancode right;
SDL_Scancode jump;
SDL_Scancode attack;
// No remapejable des del menú de servei: P1 → "1", P2 → "2".
SDL_Scancode start;
};
// 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.
// Causa de la mort actual. La passa el caller a `enterDying(cause)` per
// triar la presentació visual (`death_<key>` al YAML del player). El
// default `GENERIC` cobreix tots els callsites antics (mòmies, etc.)
// sense haver de tocar-los; els nous (caiguda per `Hole`, en el futur
// pinchos / timeout / etc.) passen la seua causa explícita.
enum class DeathCause : std::uint8_t {
GENERIC, // mort estàndard — animació `death_spinning`
HOLE, // caiguda per agujero — animació `death_hole`
};
enum class State : std::uint8_t {
GROUNDED,
AIRBORNE,
LANDING_STUN,
HURT,
// Atacant amb látigo. Estat mútuament exclusiu amb AIRBORNE: no
// es pot atacar saltant, ni saltar atacant. Cobreix swing +
// cooldown internament: durant `whip_swing_ms` es spawneja
// l'entitat WhipAttack i corre l'animació `attack_*`; durant
// `whip_cooldown_ms` posterior, el jugador segueix bloquejat
// (no input, no salt, no atac) però visualment torna a
// `stand_*`. En total, ATTACKING dura `swing_ms + cooldown_ms`;
// en acabar, torna a GROUNDED.
ATTACKING,
// Jugador que ha eixit per la porta de la sala. Despawn lògic:
// no es renderitza, no processa input, no es mou, no col·lisiona
// amb res ni rep contacte d'entitats. Idempotent: les crides
// posteriors a `exit()` són no-op. El reset el porta `respawn()`
// (cridada per GameScene en transició OUTRO → INTRO).
EXITED,
// Slot de jugador inactiu: instanciat però fora de partida. Mateix
// perfil físic que EXITED (invisible, sense input, sense col·lisió,
// ignorat pel resolver PvP), però amb semàntica diferent: no
// compta com a "ha completat la sala" ni dispara `level_complete`.
// Usat per (a) el slot del segon jugador en single-player, (b)
// tots els slots durant les transicions de sala abans del spawn
// aeri, i (c) en el futur, l'estat post-mort abans del respawn.
DISABLED,
// Mort en curs: animació `death_spinning` (3 voltes horàries) +
// pausa `respawn_delay_ms` abans del respawn. Bloqueja input i
// moviment; no rep contacte amb hazards (cf. `isDying`). Quan el
// timer s'esgota, `respawn(spawn_x_, spawn_y_)` torna a GROUNDED
// i decrementa el comptador de vides (pot anar a negatius — no
// hi ha game over).
DYING,
};
// Geometria carregada des del YAML del player (p.ex. `data/entities/player/sam.yaml`).
// `sprite_*` són
// la mida del frame (sprite 12×24 amb perspectiva falsa: visualment, els
// 12 píxels superiors són el cap i els 12 inferiors la base).
// La caixa de col·lisió és un quadrat `collision_size` concèntric amb el
// quadrat inferior del sprite (`base_size`). Els offsets els calculem
// al load a partir d'aquests tres valors; el mapa de col·lisions i el
// de dibuix són independents (el sprite pot sobresortir sense afectar).
inline static int sprite_w = 0;
inline static int sprite_h = 0;
inline static int base_size = 0;
inline static int collision_size = 0;
inline static int collision_padding = 0;
inline static int collision_x_offset = 0;
inline static int collision_y_offset = 0;
// Caixa de "contacte" més petita que la caixa de col·lisió: s'usa per
// a les interaccions actor-actor (player↔player, player↔momia/trampa),
// on volem que el contacte sigui precís i no dispari a "distància"
// de la vora del sprite. La caixa de col·lisió gran continua usant-se
// per xocar amb murs i sarcòfags (per mantenir el carril del
// passadís). Sense `hit_size` al YAML, s'iguala a `collision_size` i
// el comportament és idèntic a l'anterior.
inline static int hit_size = 0;
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);
// Vides amb què arrenca un jugador (camp `lives:` del YAML del
// jugador). El consumeix el HUD per inicialitzar el comptador del
// marcador i `Player::resetForJoin` per a re-entrades mid-partida.
[[nodiscard]] static auto getInitialLives() -> int { return death_config.initial_lives; }
Player(const std::string& anim_key, int id, float x, float y, const Controls& controls, const Map::Info& map_info, int paint_tile_index = -1);
// Update en una sola crida: manté l'API anterior per als tests i
// escenaris amb un sol jugador. Internament és equivalent a cridar
// `tick(dt)` seguit d'`applyMovement(dt)`.
void update(float delta_time) override;
// Fase 1: llig l'input d'aquest frame i emmagatzema la velocitat
// intencionada a `intended_vx_` / `intended_vy_`. No mou el jugador.
// Separada d'`applyMovement` perquè el GameScene puga resoldre
// interaccions entre jugadors (empentes, neutralitzacions head-on)
// abans que ningú consume la posició.
void tick(float delta_time);
// Fase 2: consumeix `intended_vx_`/`intended_vy_` (possiblement
// modificats pel resolver), crida `tryMove` contra el mapa i els
// altres jugadors, fa snap al passadís, clamp al playfield, avança
// l'animació i publica `on_tile_entered` si ha canviat de tile.
void applyMovement(float delta_time);
// Identificador local (0 = P1, 1 = P2…). S'usa als events del bus
// perquè els consumidors (HUD, futures regles de puntuació) sàpiguen
// quin jugador ha originat l'event. No té cap vinculació amb la
// xarxa — és únic per dins del procés.
[[nodiscard]] auto getId() const -> int { return id_; }
// Accés i mutació de la velocitat intencionada d'aquest frame. El
// GameScene els toca entre `tick` i `applyMovement` per implementar
// col·lisions jugador↔jugador (escalar a push_factor, anul·lar en
// head-on). Expressades en píxels/segon, no en píxels/frame.
// Amb inèrcia activada, `intended_v*` pot ser no-zero encara que
// l'usuari no estiga prement res (coast residual).
[[nodiscard]] auto getIntendedVX() const -> float { return intended_vx_; }
[[nodiscard]] auto getIntendedVY() const -> float { return intended_vy_; }
void setIntendedVelocity(float vx, float vy) {
intended_vx_ = vx;
intended_vy_ = vy;
}
// Velocitat crua d'input d'aquest frame (-SPEED, 0 o +SPEED per eix).
// A diferència de `intended_v*`, no té en compte la inèrcia: un jugador
// que coasta sense prémer res té `input_v* == 0`. El resolver PvP del
// GameScene l'usa per decidir "parat vs en moviment" (criteri: un
// coast sense input compta com parat — l'empenta i el head-on només
// es disparen quan hi ha intenció explícita del jugador).
[[nodiscard]] auto getInputVX() const -> float { return input_vx_; }
[[nodiscard]] auto getInputVY() const -> float { return input_vy_; }
// Llista de tots els jugadors del nivell (incloent-se ell mateix —
// la callback ignora `this`). La caixa del jugador es considera un
// sòlid a la callback de col·lisió: així `tryMove` no permet que un
// jugador travesse l'altre, i els desplaçaments parcials (push
// contra mur) s'emparen automàticament.
void setOtherPlayers(const std::vector<Player*>* all) { all_players_ = all; }
// Refresca la còpia local de `Map::Info`. Necessari quan el mapa
// (geometria, p.ex. `door_col`) canvia entre carregaments —
// típicament després del editor in-game. Sense aquest refresc, el
// `clampToPlayfield` segueix usant el corridor antic i el jugador
// no troba la nova "obertura" del mur trasser.
void setMapInfo(const Map::Info& info) { map_ = info; }
// Marcat pel resolver jugador↔jugador quan aquest frame el jugador
// és arrossegat per una empenta (no té input propi; es desplaça
// perquè l'altre l'empeny). Determina que l'animació del frame siga
// `stand_*` encara que la posició canvie. El flag es reseteja a
// l'inici de cada `tick`, així que el resolver l'ha de tornar a
// posar cada frame que el push s'haja de mantenir.
void setBeingPushed(bool pushed) { being_pushed_ = pushed; }
// Factor d'escala d'empenta jugador↔jugador (viu al YAML del mapa).
// L'usa el resolver de GameScene per al push horitzontal, i també
// `snapToLane` quan descobreix que un altre jugador li bloqueja el
// carril objectiu: per coherència, la pushada del snap va al mateix
// ritme que l'horitzontal. Si no es crida, el valor per defecte 1.0
// manté el snap a velocitat plena (comportament pre-multiplayer).
void setPushFactor(float factor) { push_factor_ = factor; }
// 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. 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; }
[[nodiscard]] auto isExited() const -> bool { return state_ == State::EXITED; }
[[nodiscard]] auto isDisabled() const -> bool { return state_ == State::DISABLED; }
[[nodiscard]] auto isDying() const -> bool { return state_ == State::DYING; }
// Resum dels estats "fora del món": EXITED i DISABLED comparteixen el
// mateix perfil físic (no es pinten, no llegeixen input, no
// col·lisionen, no participen al resolver PvP). Tots dos gates de
// l'update i el render comproven aquesta combinació amb una sola
// crida per evitar oblits si demà afegim un tercer estat-fora-del-món.
[[nodiscard]] auto isOutOfPlay() const -> bool {
return state_ == State::EXITED || state_ == State::DISABLED;
}
// Tangible per a contactes jugador↔jugador: només els estats a terra
// amb cos sòlid. AIRBORNE i HURT són intangibles (es travessen).
// EXITED tampoc — el jugador ha eixit lògicament i ja no és al món.
[[nodiscard]] auto isPvpTangible() const -> bool {
return state_ == State::GROUNDED || state_ == State::LANDING_STUN;
}
// El jugador està caient per un agujero (`Hole`): subestat dins
// d'AIRBORNE amb gravetat aplicada SENSE clamp a 0 — `jump_z_` baixa
// a negatius fins a `HOLE_FALL_DEPTH`, llavors `enterDying()`. Mentre
// dura, `checkPlayerCollisions` salta aquest jugador (cap més hazard
// l'ha de tocar). Es neteja a `enterDying()` i a `respawn()`.
[[nodiscard]] auto isFallingThroughHole() const -> bool { return falling_through_hole_; }
// Activa la caída per agujero: snap del jugador al centre (cx, cy) en
// coordenades absolutes del canvas, transició a AIRBORNE amb
// `jump_z_=0` i `falling_through_hole_=true`, i arma el clip rect
// visual a partir de `floor_y` (Y de la base del tile del forat,
// canvas píxels). Desactiva input direccional reusant la branca
// AIRBORNE existent. No-op si el jugador no està a GROUNDED.
//
// CONTRACTE: `floor_y` ha de correspondre a la vora inferior de
// l'AABB del decal trigger (`hole.y + hole.h`). Els dos callers
// actuals — `Hole::onPlayerContact` i `Player::integrateJump` —
// passen exactament aquest valor. Qualsevol caller futur ha de
// respectar la convenció: el clip rect del render es calcula com
// `floor_y - SPRITE_FLOOR_OFFSET`, i un valor incorrecte desplaçaria
// visualment el "sòl" sobre el qual el sprite del jugador desapareix.
void enterHoleFall(float center_x, float center_y, float floor_y);
// Despawn lògic: el jugador acaba de creuar la porta de sortida.
// Idempotent — només la primera crida fa la transició; les
// posteriors són no-op. La publicació del `player_exited` al bus
// la fa el caller (típicament `Door::onPlayerContact`) per
// mantenir aquesta capçalera lleugera (sense incloure event_bus).
// Definit inline perquè els consumidors de baix nivell (test
// d'entitats) puguen enllaçar Door sense arrossegar player.cpp.
void exit() {
if (state_ == State::EXITED) { return; }
state_ = State::EXITED;
intended_vx_ = 0.0F;
intended_vy_ = 0.0F;
// Important: també resetegem `input_v*` perquè el resolver
// jugador↔jugador (`resolvePlayerVsPlayer`) consulta
// `getInputVX/Y` per decidir si un jugador "es mou". Un EXITED
// amb input fantasma del moment d'entrar a la porta dispararia
// la branca head-on i bloquejaria l'altre jugador en sec.
input_vx_ = 0.0F;
input_vy_ = 0.0F;
being_pushed_ = false;
}
// Mata el jugador d'un sol toc: transició a DYING, animació
// `death_spinning`, posició congelada, input ignorat. Idempotent —
// si ja està DYING, EXITED o DISABLED, és no-op (un segon toc de
// momia mentre la roda no la reinicia). Quan l'animació acaba i
// `respawn_delay_ms` expira, `respawnFromDying()` decrementa
// `lives_remaining_`: si encara queden vides, fa `spawnFromAir`
// al spawn point; si arriba a 0, fa `disable()` i publica al bus
// `player_disabled` perquè GameScene/HUD reaccionin (slot fora de
// partida, esperant un nou START per re-entrar via "join in progress").
void enterDying(DeathCause cause = DeathCause::GENERIC);
// Coordenades del spawn de respawn (cantó superior-esquerra del
// sprite, mateixa convenció que `respawn(x, y)`). El GameScene les
// assigna després de `computeSpawn` al ctor i a cada `loadRoom`. Sense
// això, el respawn enviaria el jugador a (0, 0).
void setSpawnPoint(float x, float y) {
spawn_x_ = x;
spawn_y_ = y;
}
// Comptador d'intents (estil Mario, NO HP). Decreix a cada respawn
// post-mort. En arribar a 0 el jugador passa a DISABLED i publica
// `player_disabled` al bus; pot tornar a partida via "join in
// progress" (GameScene::joinPlayer crida `resetForJoin`). Públic
// per a HUD i tests.
[[nodiscard]] auto getLivesRemaining() const -> int { return lives_remaining_; }
// Activa la immunitat per `duration_ms` ms. Mentre `isImmune()` és
// cert, `enterHurt` i `enterDying(GENERIC)` són no-op — els hazards no
// poden tocar el jugador. La caiguda per forats (`enterDying(HOLE)`)
// segueix funcionant: els forats no són dany convencional, són una
// sortida lògica del món. Idempotent: una segona crida reescriu el
// timer (no acumula). Visualment, el render alterna la paleta del
// sprite amb una paleta plana cada `immunity_config.blink_period_ms`
// ms. Si `duration_ms <= 0` és no-op (manera de cancel·lar
// explícitament la immunitat seria `setImmunity(0)` — equivalent al
// comportament natural quan el timer s'esgota).
void setImmunity(float duration_ms) {
if (duration_ms > 0.0F) { immunity_remaining_ms_ = duration_ms; }
}
// Cert mentre el jugador té immunitat activa. Comprovat per
// `enterHurt` i `enterDying` per filtrar els hazards. La causa HOLE
// ignora aquest gate (vegeu `enterDying`).
[[nodiscard]] auto isImmune() const -> bool { return immunity_remaining_ms_ > 0.0F; }
// Reset complet a l'estat inicial: posició a (x, y), velocitats a 0,
// estat GROUNDED, qualsevol flag de control reinicialitzat. Cridat
// per GameScene a la transició ROOM_OUTRO → ROOM_INTRO. Idempotent
// (es pot cridar des de qualsevol estat). Es manté disponible per a
// qualsevol cas futur on calgui aparèixer el jugador ja a terra
// sense la cinemàtica de caiguda; el flux estàndard d'arrencada de
// sala usa `disable()` + `spawnFromAir()`.
void respawn(float x, float y);
// Restaura el comptador de vides al valor inicial configurat
// (`death_config.initial_lives`). Cridat per la GameScene quan un
// slot inactiu pulsa START per entrar mid-partida (regla 6 de la
// FSM de joc): el jugador entra amb les vides plenes. La resta de
// l'estat (posició, animació, flags) la reseteja `spawnFromAir`
// que GameScene crida tot seguit.
void resetForJoin();
// Posa el jugador en el slot inactiu (DISABLED). Idempotent: la
// segona crida és no-op. Mateix patró que `exit()` per simetria.
// No publica res al bus — el GameScene decideix quan disparar el
// `spawnFromAir` posterior, i la transició DISABLED↔jugant és
// ortogonal a `level_complete`. Útil per:
// - el slot del segon jugador en single-player,
// - el període entre setState(ROOM_INTRO) i el trigger del spawn,
// - el futur estat post-mort (abans del respawn temporitzat).
void disable();
// Cinemàtica d'aparició: posiciona el jugador al seu tile final
// (x, y al sistema de coordenades del sprite) i el deixa en
// AIRBORNE a una altura derivada de la geometria del playfield —
// prou per arrencar fora del marc i caure recte fins al sòl. La
// velocitat horitzontal queda a zero (caiguda perfectament
// vertical). En tocar terra, el flux normal d'AIRBORNE→
// LANDING_STUN→GROUNDED el reactiva amb input. Reutilitzable per
// al respawn post-mort: mateixa entrada cinemàtica.
void spawnFromAir(float x, float y);
// Arma un preset de reverb (catàleg a `data/config/sounds.yaml`) per al
// pròxim aterratge d'aquest jugador. Pensat per a la cinemàtica
// d'INTRO de sala: GameScene crida aquesta funció just després de
// `spawnFromAir`, i el primer LAND consumeix el preset (queda buit
// immediatament). Els salts posteriors del joc, els respawns
// post-mort i el reset general no rearmen res. `preset` buit
// desactiva el reverb del proper LAND (no afecta cap LAND posterior).
void armNextLandReverb(std::string preset) { next_land_reverb_preset_ = std::move(preset); }
// 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_}`.
// El PaintLayer filtra pintabilitat i re-publica `tile_painted` a qui
// correspongui (sarcòfags). Si el bus no s'injecta, no emet res.
void setEventBus(EventBus* bus) { bus_ = bus; }
// Injecta el manager d'entitats del nivell. L'usa `integrateJump` per
// detectar aterratges sobre decals-trigger (forats, plaques de
// pressió...) i disparar la transició corresponent en lloc del
// LANDING_STUN normal. Propietat externa (mateixa convenció que
// `setEventBus`); si no s'injecta, l'aterratge segueix el camí
// normal (els tests stand-alone del Player no necessiten injectar-lo).
void setEntityManager(EntityManager* manager) { entity_manager_ = manager; }
// Tile actual del jugador (centre de `baseBox()` convertit a
// coordenades de tile del tilemap, usant Map::tile_size i
// Map::tilemap_origin_*). Retorna (col, row).
[[nodiscard]] auto getCurrentTile() const -> std::pair<int, int>;
// Rectangle de col·lisió del jugador, en coordenades del canvas. Usat
// pel mapa de col·lisions (tryMove contra murs/sarcòfags).
[[nodiscard]] auto getBaseBox() const -> SDL_FRect;
// Rectangle de contacte actor-actor (hit-box), més petit que la caixa
// base i concèntric amb ella. L'usen `checkPlayerCollisions`
// (momies, trampes, porta) i el resolver jugador↔jugador per
// decidir quan es toquen.
[[nodiscard]] auto getHitBox() const -> SDL_FRect;
// Versió estàtica: deriva la hit-box a partir d'una caixa base
// arbitrària (p. ex. la posició proposada per `tryMove` abans
// d'aplicar-la). Evita recalcular `hit_x_offset`/`hit_y_offset` fora
// de Player.
[[nodiscard]] static auto hitBoxFromBase(const SDL_FRect& base) -> SDL_FRect;
// Override del hook de Sprite: `MovingSprite::tryMove` envia aquesta
// caixa al callback de col·lisió. La base del Player és més petita
// que el sprite (el cap pot sobresortir per murs/sarcòfags sense
// 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()`.
[[nodiscard]] auto baseBox() const -> SDL_FRect { return getBaseBox(); }
// Callback de col·lisió per a `tryMove`: true si la caixa proposada
// xoca amb el mapa o amb un altre jugador. Membre per mantenir la
// complexitat cognitiva d'`applyMovement` sota el llindar.
[[nodiscard]] auto collidesWithWorld(const SDL_FRect& box) const -> bool;
// Clamp de la caixa de col·lisió al playfield (no al sprite sencer:
// el cap pot sobresortir pel mur superior sense afectar el joc).
void clampToPlayfield();
// Snap cap al passadís perpendicular a la direcció premuda. Si la caixa
// de col·lisió està a ≤ collision_size/2 d'una lane, s'hi aproxima
// gradualment (a velocitat de moviment). Permet canviar de direcció
// sense haver d'alinear-se píxel-perfect en l'encreuament. Els dos
// wrappers deleguen a `snapToLane` només canviant l'eix actiu.
void snapToHorizontalLane(float delta_time);
void snapToVerticalLane(float delta_time);
// Nom d'animació per al `facing_` actual. Composat com a `prefix +
// suffix` via els dos helpers estàtics (declarats junt amb `Facing`
// més avall) — el prefix depèn de l'estat (walk/stand/jump/stun/hurt/
// attack/recover), el sufix del cardinal (`_north`/`_south`/`_east`/
// `_west`).
[[nodiscard]] auto animationName(bool moving) const -> std::string;
// Aplica el snap cap a una lane concreta amb la mecànica de fases
// 1/2 + push de blockers. Els wrappers (`snapToHorizontalLane` /
// `snapToVerticalLane`) decideixen la lane diana (grid regular, o
// corridor de la porta com a candidata especial).
void snapTowardsLane(
float delta_time,
float target_lane,
float (Player::*get_pos)() const,
void (Player::*set_pos)(float));
// Cert si un step (dx, dy) trauria la base box fora del playfield
// (mur extern). Excepció: si el moviment és cap amunt (dy<0), la
// porta està OBERTA i la box probe queda dins del rang X del
// corridor, no és bloqueig — el jugador hi pot sortir. S'usa per
// suprimir el snap a lane quan el jugador no pot avançar perquè
// pica contra un mur exterior.
[[nodiscard]] auto wouldHitOuterWall(float dx, float dy) const -> bool;
// Quan el snap lateral trobaria un altre jugador al carril objectiu,
// l'empentem el mateix step en el mateix eix. Així `this` pot entrar
// al passadís encara que l'altre estiga estacionat al mitj — mateix
// contracte que el push horitzontal però disparat pel snap. Retorna
// true si la push ha quedat neta (ningú xoca amb el mapa ni amb un
// tercer jugador); false fa que el caller revertisca el propi snap.
// Marca els blockers amb `being_pushed_` per a la seua animació.
auto tryPushSnapBlockers(
float signed_step,
float (Player::*get_pos)() const,
void (Player::*set_pos)(float)) -> bool;
// Refresca `controls_` des d'`Options::controls()` cada frame. Replica
// el patró d'AEE on `KeyRemap::update` reflecteix Options::keys_game al
// buffer del teclat cada tick: així un remap fet al menú de servei té
// efecte immediat sense haver de tornar a entrar a la GameScene.
void refreshControlsFromOptions();
int id_{-1};
Controls controls_;
// Velocitat intencionada calculada a `tick` i aplicada a
// `applyMovement`. El GameScene pot sobreescriure-la entremig per
// resoldre interaccions jugador↔jugador (empentes, head-on).
// També actua com a estat físic persistent entre frames: amb inèrcia
// activada, si l'usuari solta l'input, aquí queda la velocitat
// residual que `tick` anirà decaient linealment cap a 0.
float intended_vx_{0.0F};
float intended_vy_{0.0F};
// Velocitat desitjada per input aquest frame (teclat + gamepad). Cada
// eix és -SPEED, 0 o +SPEED. És la "intenció del jugador"; la física
// resultant viu a `intended_v*` i pot diferir (coast sense input).
float input_vx_{0.0F};
float input_vy_{0.0F};
// Acumulador de temps en moviment continu. Creix cada frame amb
// `|input_v*| > 0` o `|intended_v*| > 0`, i es reseteja a 0 només
// quan NO hi ha ni input ni velocitat residual. Activa la inèrcia
// quan passa el llindar configurat al YAML (`threshold_ms`); no es
// buida mai enmig d'un coast, ni tan sols en canvis de direcció,
// així un toc curt en sentit oposat no "frena en sec" un coast llarg.
float motion_time_ms_{0.0F};
// Punter a la llista de tots els jugadors del nivell (propietat del
// GameScene). `nullptr` = jugador aïllat, sense col·lisió inter-P.
const std::vector<Player*>* all_players_{nullptr};
// Frame actual el jugador està sent empentat (no té input propi).
// Serveix només per a l'elecció d'animació — la posició ja la
// gestiona `intended_vx_/vy_` escalats pel resolver.
bool being_pushed_{false};
// Preset de reverb (catàleg de `data/config/sounds.yaml`) que el pròxim
// LAND ha de fer servir; buit = so sec. L'arma `armNextLandReverb`
// (cridada per GameScene al spawn aeri d'INTRO) i el consumeix
// `integrateJump` quan el jugador toca terra. Cap altre punt
// (respawn, exit, disable) el toca: així només sona reverberat el
// primer LAND de la cinemàtica d'entrada.
std::string next_land_reverb_preset_;
// Escala d'empenta usada per `snapToLane` quan descobreix un blocker
// (mateixa del resolver). 1.0 = sense escalar (comportament si
// GameScene no ha cridat setPushFactor).
float push_factor_{1.0F};
// Còpia plana de la geometria del mapa + punters a `collides`/
// `inside_playfield`. POD trivial; copiada al ctor i no muta durant
// la vida del Player. Vol dir que aquest Player només és vàlid per
// a un mapa concret — si el mapa canvia, la Scene ha de
// reinstanciar-lo.
Map::Info map_;
// Direcció "cap on mira" el jugador. Una entrada per cada cardinal: el
// codi del joc no toca mai el flip — l'oest reprodueix `*_west`, que al
// YAML declara `flip_h: true`, i és el motor d'animació qui aplica el
// flip al sprite. Els moviments diagonals prenen el component lateral
// (EAST/WEST); en aturar-se es preserva l'última orientació perquè la
// versió stand coincideixi.
enum class Facing : std::uint8_t { EAST,
WEST,
NORTH,
SOUTH };
Facing facing_{Facing::EAST};
// Helpers de composició de `animationName`. Han de viure ací (després
// de la declaració de `Facing`) perquè la signatura el referencia.
static auto facingSuffix(Facing facing) -> const char*;
static auto statePrefix(State state, bool moving, bool in_attack_swing) -> const char*;
// Cardinal complet (N/S/E/W) usat per a la detecció de girs de 180°.
// Pública perquè el `WhipAttack` la passa a `enterHurt` quan colpeja
// un altre jugador (friendly fire).
public:
enum class Cardinal : std::uint8_t { NONE,
NORTH,
SOUTH,
EAST,
WEST };
private:
// Configuració d'inèrcia llegida del bloc `inertia:` del YAML. Amb
// `brake_time_ms == 0` (o bloc absent) la inèrcia està desactivada i
// el comportament és instantani: soltar input para el jugador en sec.
struct InertiaConfig {
int brake_time_ms{0}; // Temps en ms perquè la velocitat caiga de ±SPEED a 0.
int threshold_ms{0}; // Temps mínim en moviment continu per activar la inèrcia.
};
static InertiaConfig inertia_config;
// Integra un eix. Si hi ha input en aquest eix, la velocitat física
// salta a aquest valor (acceleració instantània). Si no, i hi ha
// input en l'altre eix (`any_input == true`), la velocitat d'aquest
// eix es posa a 0 en sec: el jugador està fent un gir de 90°, no
// soltant l'input — no volem que arrossegue en diagonal. Només quan
// l'usuari deixa anar tot l'input (`any_input == false`) s'activa la
// inèrcia de frenada (si `motion_time_ms` ha passat el llindar).
static void integrateAxis(float& phys, float input, float delta_time, float motion_time_ms, bool any_input);
// Configuració del gir de 180°, llegida del bloc `turn:` del YAML del
// player. Si `duration_ms` és 0 (o el bloc no existeix) la mecànica
// queda desactivada i els canvis de direcció són instantanis.
struct TurnConfig {
int duration_ms{0};
Cardinal w_to_e{Cardinal::SOUTH};
Cardinal e_to_w{Cardinal::NORTH};
Cardinal n_to_s{Cardinal::EAST};
Cardinal s_to_n{Cardinal::WEST};
};
// Definida out-of-class al .cpp: dins de la classe, els initializers
// 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.
// Fitxer .wav reproduït en entrar a HURT (relatiu a `data/audio/sounds/`).
// Mateixa convenció que la resta de sons del jugador: el basename val
// de clau al Resource::Cache i de path. Buit o absent = entrada
// silenciosa.
std::string sound_key;
};
static HurtConfig hurt_config;
// Llig el bloc `hurt:` del YAML i n'omple `hurt_config`.
static void readHurtBlock(const fkyaml::node& yaml);
// Configuració del látigo del jugador llegida del bloc `whip:` del
// YAML. `range_tiles` defineix la longitud de la hitbox abans de
// retallar; `swing_ms` és la durada de l'animació d'atac (des
// d'`enterAttacking` fins a desbloquejar el cooldown); `cooldown_ms`
// afegeix una fase posterior durant la qual el jugador segueix
// bloquejat però la hitbox ja no fa dany. `damage` queda llegit per
// a futur; per ara els objectius reben sempre `enterHurt`.
struct WhipConfig {
float range_tiles{0.0F};
int swing_ms{0};
int cooldown_ms{0};
int damage{0};
// Fitxer .wav reproduït al moment de disparar el swing (relatiu a
// `data/audio/sounds/`). Convenció: el basename val tant de clau al
// Resource::Cache com de path. Buit o absent = swing silenciós.
std::string sound_key;
};
static WhipConfig whip_config;
// Llig el bloc `whip:` del YAML i n'omple `whip_config`. Si manca el
// bloc, els camps queden a 0 i el jugador no pot atacar (el flanc
// d'input es perd silenciosament).
static void readWhipBlock(const fkyaml::node& yaml);
// Configuració de mort/respawn llegida del bloc `death:` del YAML.
// `respawn_delay_ms` és la pausa entre la fi de `death_spinning` i la
// reaparició al spawn; `initial_lives` és el comptador d'intents amb
// què naix cada Player. Si el bloc manca, defaults segurs (0/3).
struct DeathConfig {
int respawn_delay_ms{0};
int initial_lives{3};
};
static DeathConfig death_config;
// Llig el bloc `death:` del YAML i n'omple `death_config`. Si manca,
// `respawn_delay_ms` queda a 0 (el respawn és immediat al final de
// l'animació) i `initial_lives` es manté al default (3).
static void readDeathBlock(const fkyaml::node& yaml);
// Configuració de la immunitat post-spawn llegida del bloc `immunity:`
// del YAML. `duration_ms` és el temps per defecte que dura cada
// immunitat (l'invoca `respawn`). `blink_period_ms` és el mig-cicle del
// parpadeig visual: cada `blink_period_ms` ms, el render alterna
// entre la paleta normal i la paleta `palette_key`. Si `duration_ms`
// és 0 (o el bloc manca) la immunitat queda desactivada i el
// comportament és l'antic.
struct ImmunityConfig {
float duration_ms{0.0F};
int blink_period_ms{0};
// Clau al Resource::Cache de la paleta plana usada al blink (basename
// del .pal a `data/entities/player/`). Buida = no hi ha alternança visual,
// només la finestra lògica d'immunitat (que sense feedback visual
// pot ser confusa, però no és incorrecte).
std::string palette_key;
};
static ImmunityConfig immunity_config;
// Llig el bloc `immunity:` del YAML i n'omple `immunity_config`. Si
// hi ha `palette`, registra la paleta corresponent al Resource::Cache
// (path: `data/entities/player/<palette_key>.pal`). Mateix patró que els altres
// readers — `loadFrom` el crida al final.
static void readImmunityBlock(const fkyaml::node& yaml);
// Activa l'estat ATTACKING en la direcció cardinal indicada (la
// direcció cap on mira el jugador) i publica `whip_spawn` al bus
// perquè el GameScene cree el `WhipAttack` corresponent. Posa el
// timer `attacking_remaining_ms_` a `swing_ms + cooldown_ms`.
void enterAttacking(Cardinal facing);
// Cardinal corresponent al `facing_` actual del sprite — la direcció
// cap on mira el jugador. Mapeig 1:1 amb `Facing` (mai NONE).
[[nodiscard]] auto currentFacingCardinal() const -> Cardinal;
// Activa l'estat HURT: el jugador queda projectat amb velocitat
// inicial `hurt_config.initial_speed_px_per_s`. La direcció es tria
// així:
// - 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.
// Pública: la dispara tant la cinemàtica de "caure damunt" com el
// `WhipAttack` quan colpeja un altre jugador (friendly fire).
public:
void enterHurt(Cardinal preferred = Cardinal::NONE);
private:
// Tria la direcció definitiva del HURT. Si `preferred` cabe el
// recorregut natural complet, la retorna; d'altra manera tria la
// direcció amb més recorregut lliure entre els 4 cardinals.
[[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};
// Subestat dins d'AIRBORNE: `enterHoleFall` el posa a true; mentre dura,
// `integrateJump` no clampa `jump_z_` a 0 — deixa baixar a negatius i
// dispara `enterDying()` quan creua `HOLE_FALL_DEPTH`. Es neteja a
// `enterDying()` i `respawn()`.
bool falling_through_hole_{false};
// Y absoluta (canvas píxels) per sota de la qual el `Player::render`
// retalla el sprite mentre `falling_through_hole_=true`. La fixa
// `enterHoleFall` a `floor_y - SPRITE_FLOOR_OFFSET` i la neteja
// `respawn`. Sense ús quan no estem caient per un forat.
float hole_clip_bottom_y_{0.0F};
// Profunditat (en píxels per sota del sòl) que ha de superar el jugador
// mentre cau per un agujero abans que `integrateJump` el mate. Constant
// per ara (ajustable a posteriori si el feel no és bo).
static constexpr float HOLE_FALL_DEPTH = 20.0F;
// Píxels transparents a la base del sprite del jugador (16×32). El
// sprite "camina pel mig del passadís en falsa perspectiva", així que
// la silueta no toca la vora inferior del rect del sprite — hi queda
// un marge transparent. Quan armem el clip rect del forat, restem
// aquest offset a la `floor_y` perquè la línia de retall coincideixi
// visualment amb la base del tile, no amb la vora del sprite.
static constexpr float SPRITE_FLOOR_OFFSET = 3.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};
// Temps restant d'immunitat (ms). Decreix cada frame al `tick`,
// independentment de l'estat — la immunitat és ortogonal al state
// machine. Mentre > 0, `enterHurt` i `enterDying(GENERIC)` són no-op.
// L'arma `setImmunity()` (cridada per `respawn` amb la durada del
// YAML), i el render alterna paleta cada `immunity_config.blink_period_ms`.
float immunity_remaining_ms_{0.0F};
// Còpia de la paleta normal del sprite (extreta de `surface_->getPalette()`
// al ctor). El render del blink l'usa per restaurar la paleta després
// d'aplicar la versió plana cada mig-cicle. Cachejada perquè la
// Surface pot ser compartida entre dos jugadors del mateix personatge
// i no és segur consultar-la entre swaps.
Palette normal_palette_{};
// 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};
// Mateixa idea per al botó d'atac: edge-detection independent
// teclat/mando, perquè mantenir la tecla no genere atacs en cadena.
bool attack_held_keyboard_{false};
bool attack_held_gamepad_{false};
// Temps que queda dins de l'estat ATTACKING (swing + cooldown). Quan
// arriba a 0, transició a GROUNDED. Mentre dura, el jugador queda
// bloquejat i no rep input direccional ni de salt ni d'atac.
float attacking_remaining_ms_{0.0F};
// Estat de DYING. `death_animation_complete_` es marca quan
// `animationIsCompleted()` retorna true per primera vegada al cicle
// actual; només llavors comença a descomptar `respawn_remaining_ms_`.
// Quan el delay arriba a 0, `respawnFromDying()` aplica `respawn(...)`
// i decreix el comptador de vides.
bool death_animation_complete_{false};
float respawn_remaining_ms_{0.0F};
// Comptador d'intents (estil Mario). Inicialitzat a `death_config.
// initial_lives` al ctor; decreix una vegada per cada respawn
// post-mort (no per respawns de canvi de sala). En arribar a 0,
// `respawnFromDying` posa el jugador a DISABLED i publica
// `player_disabled` al bus. `resetForJoin()` el restaura al valor
// inicial quan el jugador re-entra mid-partida.
int lives_remaining_{0};
// Coordenades del spawn de respawn (cantó superior-esquerra del
// sprite). Les assigna el GameScene via `setSpawnPoint`. 0/0 fins que
// el GameScene n'invoque el setter al ctor / `loadRoom`.
float spawn_x_{0.0F};
float spawn_y_{0.0F};
// Aplica el respawn al final del cicle DYING: decreix vides (sense
// clamp), neteja els flags interns del cicle i delega a `respawn`
// amb les coordenades del spawn.
void respawnFromDying();
// Branca de `applyMovement` per a DYING. Avança l'animació, detecta
// la fi i descompta el delay; al final, dispara el respawn.
void applyDyingMovement(float delta_time);
// Llig el botó de salt aquest frame (teclat + mando assignat) i,
// amb edge-detection, retorna true si s'ha de despegar el salt.
[[nodiscard]] auto consumeJumpEdge() -> bool;
// Anàleg per a l'atac: edge-detection del botó d'atac (teclat
// `controls_.attack` + `SDL_GAMEPAD_BUTTON_WEST`).
[[nodiscard]] auto consumeAttackEdge() -> bool;
// Inicia un salt: copia `intended_v*` com a velocitat fixada del salt,
// posa `jump_vz_ = impulse`, transiciona a AIRBORNE.
void startJump();
// 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,
// s'aplica `pending_cardinal_`. `last_input_cardinal_` recorda l'últim
// cardinal d'input no nul: serveix per disparar el gir només quan
// l'usuari canvia d'opinió, no la primera vegada que prem una tecla
// des de l'estat idle.
float turning_remaining_ms_{0.0F};
Cardinal pending_cardinal_{Cardinal::NONE};
Cardinal intermediate_cardinal_{Cardinal::NONE};
Cardinal last_input_cardinal_{Cardinal::NONE};
// Aplica un cardinal a (`facing_`, flip). NONE no fa res — preserva el
// facing actual (cas idle: l'última orientació es manté).
void applyCardinal(Cardinal c);
// Actualitza facing_/flip i l'estat del gir a partir de l'input del frame.
// Extret de `update` per acotar la complexitat cognitiva (clang-tidy).
void updateFacingAndTurn(float delta_time, float vx, float vy);
static auto cardinalFromInput(float vx, float vy) -> Cardinal;
static auto cardinalFromString(const std::string& s) -> Cardinal;
static auto opposite(Cardinal c) -> Cardinal;
static auto intermediateFor(Cardinal target) -> Cardinal;
// Bus del nivell (opcional). Propietat externa (EntityManager); el
// Player només en guarda un punter per publicar events.
EventBus* bus_{nullptr};
// Manager d'entitats del nivell (opcional). Propietat externa
// (GameScene); l'usa `integrateJump` per consultar
// `findFloorTriggerAt` a l'aterratge i decidir si el frame del LAND
// ha de disparar `enterHoleFall` en lloc d'un LANDING_STUN normal.
EntityManager* entity_manager_{nullptr};
// Últim tile publicat. Inicialitzats a un sentinel fora de rang perquè
// el primer frame en què el bus estigui connectat ja emeti un event.
int last_tile_col_{-9999};
int last_tile_row_{-9999};
// Índex de tile del tileset amb què aquest jugador pinta. Llegit del
// YAML del mapa (`player1_paint_tile` / `player2_paint_tile`) i passat
// al ctor per GameScene. -1 desactiva el pintat (p.ex. al test
// d'arrencada o en un escenari que no vulgui feedback visual).
int paint_tile_index_{-1};
};