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

999 lines
50 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/lighting.hpp"
#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`
};
// Direcció cardinal completa. Pública perquè el `WhipAttack` la passa a
// `enterHurt` quan colpeja un altre jugador (friendly fire). Declarada
// aquí (a sobre de `Config` perquè `TurnConfig` l'utilitza).
enum class Cardinal : std::uint8_t { NONE,
NORTH,
SOUTH,
EAST,
WEST };
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,
};
// Configuració per-instància del Player. Conté tot el que abans vivia
// com a `inline static` o `static *_config`: així cada jugador (P1=sam,
// P2=morcus) pot tenir paràmetres diferents llegits del seu YAML.
// GameScene la carrega amb `Config::loadFromYaml` abans d'instanciar
// cada Player i la passa al ctor (mou-la). Per refrescar-la entre
// sales s'utilitza `Player::setConfig`.
//
// Els sub-structs (`InertiaConfig`, `TurnConfig`, ...) viuen dins de
// `Config` per agrupar-los visualment amb la resta del config — no hi
// ha cap raó tècnica per haver-los a fora.
struct Config {
// ---- Geometria del sprite i col·lisió --------------------------
// `sprite_*` són la mida del frame (sprite 12×24 amb perspectiva
// falsa). La caixa de col·lisió és un quadrat `collision_size`
// concèntric amb el quadrat inferior del sprite (`base_size`).
// Els offsets es calculen al load.
int sprite_w{0};
int sprite_h{0};
int base_size{0};
int collision_size{0};
int collision_padding{0};
int collision_x_offset{0};
int collision_y_offset{0};
// Caixa de contacte actor-actor (més petita que la col·lisió).
int hit_size{0};
int hit_x_offset{0};
int hit_y_offset{0};
// Alçada del cos en píxels (filtre de col·lisió per altura).
int body_height{0};
// ---- Emissió de llum -----------------------------------------
// Llegit del bloc opcional `light:` del YAML.
// `radius` → 0 = no emet.
// `flicker` → activa modulació tipus foc.
// `color` → tint en mode additive (ignorat en subtractive).
// `offset` → desplaçament del centre respecte el sprite (Y avall).
int light_radius{0};
bool light_flicker{false};
std::uint8_t light_color_r{255};
std::uint8_t light_color_g{255};
std::uint8_t light_color_b{255};
int light_offset_x{0};
int light_offset_y{0};
// Paràmetres del flicker resolts (global + override del YAML +
// override de sala). Només té efecte si `light_flicker` és true.
Lighting::FlickerConfig flicker_params{};
// ---- Sub-structs de gameplay ---------------------------------
// Inèrcia: amb `brake_time_ms == 0` el comportament és instantani.
struct InertiaConfig {
int brake_time_ms{0};
int threshold_ms{0};
};
// Gir de 180°: amb `duration_ms == 0` els canvis de direcció són
// instantanis. `Cardinal` ha d'estar visible — declarada a sobre.
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};
};
// Salt: amb `impulse_px_per_s == 0` el botó no té efecte.
struct JumpConfig {
float impulse_px_per_s{0.0F};
float gravity_px_per_s2{0.0F};
int landing_stun_ms{0};
std::string sound_key;
std::string land_sound_key;
};
// HURT (estat post-impacte). Amb `initial_speed_px_per_s == 0`
// entrada/sortida instantànies (sense moviment).
struct HurtConfig {
float initial_speed_px_per_s{0.0F};
int brake_time_ms{0};
std::string sound_key;
};
// Látigo: hitbox `range_tiles`, swing `swing_ms`, cooldown
// `cooldown_ms`. Per ara `damage` només es llegeix.
struct WhipConfig {
float range_tiles{0.0F};
int swing_ms{0};
int cooldown_ms{0};
int damage{0};
std::string sound_key;
};
// Mort/respawn: `respawn_delay_ms` entre l'animació i la
// reaparició; `initial_lives` és el comptador d'intents.
struct DeathConfig {
int respawn_delay_ms{0};
int initial_lives{3};
};
// Immunitat post-spawn. `duration_ms == 0` desactiva la mecànica.
// `palette_key` (basename del .pal) és la paleta plana del blink.
struct ImmunityConfig {
float duration_ms{0.0F};
int blink_period_ms{0};
std::string palette_key;
};
InertiaConfig inertia{};
TurnConfig turn{};
JumpConfig jump{};
HurtConfig hurt{};
WhipConfig whip{};
DeathConfig death{};
ImmunityConfig immunity{};
// Llig tot el YAML del personatge i retorna un `Config` poblat.
// Substitueix l'antic `Player::loadFrom` que mutava estàtics.
// Llança si manca el bloc `collision:` (obligatori).
[[nodiscard]] static auto loadFromYaml(const std::string& yaml_path) -> Config;
// Aplica l'override de sala (bloc `lighting.emitters.player` del
// YAML del mapa) sobre els camps de llum d'aquesta Config. Es
// crida després de `loadFromYaml` i abans de passar la Config al
// ctor de Player (o a `setConfig` en transicions de sala).
void applyRoomLightOverride();
};
Player(Config cfg, const std::string& anim_key, int id, float x, float y, const Controls& controls, const Map::Info& map_info, int paint_tile_index = -1);
// Reemplaça el `Config` actual del Player. Cridat per GameScene al
// canvi de sala per re-aplicar valors base + override de la sala nova.
// No toca cap estat de joc (lives, posició, FSM): només la config.
void setConfig(Config cfg);
// Accés a la config viva (lectura). El callsites que abans accedien
// a estàtics fan servir `getters d'instància` (sota); aquest és per
// codi que vol l'objecte sencer.
[[nodiscard]] auto getConfig() const -> const Config& { return cfg_; }
// Vides amb què arrenca un jugador (camp `lives:` del YAML). El
// consumeix el HUD per inicialitzar el marcador i `resetForJoin`
// per a re-entrades mid-partida.
[[nodiscard]] auto getInitialLives() const -> int { return cfg_.death.initial_lives; }
// Geometria del cos — instance getters dels camps de `cfg_` que
// alguns callsites externs (game_scene) consulten directament.
[[nodiscard]] auto getBodyHeight() const -> int { return cfg_.body_height; }
[[nodiscard]] auto getSpriteW() const -> int { return cfg_.sprite_w; }
[[nodiscard]] auto getSpriteH() const -> int { return cfg_.sprite_h; }
[[nodiscard]] auto getCollisionSize() const -> int { return cfg_.collision_size; }
[[nodiscard]] auto getCollisionYOffset() const -> int { return cfg_.collision_y_offset; }
// Emissió de llum (mateix paper que `Entity::getLightRadius` per a
// les entitats). El centre puja amb el salt: durant `jump_z_>0` el
// sprite es dibuixa amb `pos_.y - jump_z_`, i la llum ha de seguir-lo
// per a que el cercle "salti" amb el jugador, no es quedi al sòl.
[[nodiscard]] auto getLightRadius() const -> int { return cfg_.light_radius; }
[[nodiscard]] auto getLightFlicker() const -> bool { return cfg_.light_flicker; }
[[nodiscard]] auto getLightColorR() const -> std::uint8_t { return cfg_.light_color_r; }
[[nodiscard]] auto getLightColorG() const -> std::uint8_t { return cfg_.light_color_g; }
[[nodiscard]] auto getLightColorB() const -> std::uint8_t { return cfg_.light_color_b; }
[[nodiscard]] auto getLightOffsetX() const -> int { return cfg_.light_offset_x; }
[[nodiscard]] auto getLightOffsetY() const -> int { return cfg_.light_offset_y; }
[[nodiscard]] auto getFlickerParams() const -> const Lighting::FlickerConfig& { return cfg_.flicker_params; }
// Seed estable per al flicker: reusem `id_` (0 o 1 en multijugador
// local) amb un offset gran per evitar col·lisions amb el contador
// d'Entity (que arrenca des de 1). Així P1 i P2 oscil·len en fases
// pròpies que no s'encavalquen amb cap entitat.
[[nodiscard]] auto getLightSeed() const -> int { return id_ + 1000; }
[[nodiscard]] auto getLightCenter() const -> SDL_FPoint;
// 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_; }
// Stats que pinta el HUD. El Player n'és la font de veritat; cada
// mutació es publica via `player_stats_changed` al bus (vegeu
// `publishStatsChanged`). Els getters són per a tests i diagnòstic;
// el HUD no els consulta — escolta el bus.
[[nodiscard]] auto getLives() const -> int { return lives_remaining_; }
[[nodiscard]] auto getScore() const -> int { return score_; }
[[nodiscard]] auto getTreasures() const -> int { return treasures_; }
// Acumuladors. Deltas positius i negatius (sense clamp). Publiquen
// `player_stats_changed` només si el delta no és 0, per estalviar
// soroll al bus. Els consumidors típics: GameScene reparteix puntuació
// de sarcs i pickups (futur) cridant aquests mètodes.
void addScore(int delta);
void addTreasures(int delta);
// 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>(cfg_.collision_y_offset + cfg_.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. `setImmunity(0)` cancel·la la immunitat activa (sobreescriu el
// timer a 0) — equivalent al comportament natural quan el timer
// s'esgota. Valors negatius es clampejen a 0.
void setImmunity(float duration_ms) {
immunity_remaining_ms_ = duration_ms > 0.0F ? duration_ms : 0.0F;
}
// 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]] auto hitBoxFromBase(const SDL_FRect& base) const -> 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:
// Sub-handlers de tick(), un per estat amb sortida prematura. Mantenir-los
// a part redueix la complexitat cognitiva del dispatcher principal.
void tickDying();
void tickLandingStun(float delta_time);
void tickAirborne();
void tickAttacking(float delta_time);
void tickHurt(float delta_time);
void tickGroundedInput(float delta_time);
// 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();
// Tot el config del personatge (geometria, llum, jump, hurt, whip,
// death, immunity, turn, inertia). Llegit del YAML per instància
// (P1 i P2 poden tenir valors diferents) i mogut al ctor.
Config cfg_;
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*;
// 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).
void integrateAxis(float& phys, float input, float delta_time, float motion_time_ms, bool any_input) const;
// 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};
// Puntuació acumulada i nombre de tresors recollits. Es modifiquen
// via `addScore` / `addTreasures` (o reset a `resetForJoin`). El HUD
// els sincronitza via `player_stats_changed`.
int score_{0};
int treasures_{0};
// Publica un snapshot complet (vides/score/tresors) al bus si està
// injectat. Centralitza la generació de l'event perquè cap mutador
// se l'oblide.
void publishStatsChanged();
// 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 opposite(Cardinal c) -> Cardinal;
auto intermediateFor(Cardinal target) const -> Cardinal;
public:
// Helper pur: tradueix un string YAML ("north", "south", ...) al
// Cardinal corresponent. Públic perquè `Config::loadFromYaml` el crida
// des d'un helper de la TU anònima a player.cpp.
static auto cardinalFromString(const std::string& s) -> Cardinal;
private:
// 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};
};