#pragma once #include #include #include #include #include #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_` 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; // Radi de la llum emesa pel jugador (en píxels del canvas), llegit // del bloc opcional `light: { radius: N, flicker: bool, color: [r,g,b], offset: [x,y] }` // del YAML. 0 = no emet. Es declara estàtic com la resta de configs // del jugador (immunity, jump, ...): tots els jugadors del mateix YAML // comparteixen el valor. `light_flicker` activa la modulació "tipus // foc" del radi; `light_color_*` són el tint quan la sala usa mode // additive (en subtractive s'ignora); `light_offset_*` desplaça el // centre del cercle respecte el centre del sprite (Y creix avall: // `y: -3` puja la llum 3 px). inline static int light_radius = 0; inline static bool light_flicker = false; inline static std::uint8_t light_color_r = 255; inline static std::uint8_t light_color_g = 255; inline static std::uint8_t light_color_b = 255; inline static int light_offset_x = 0; inline static int light_offset_y = 0; // Paràmetres del flicker resolts per al jugador (combinació del // global + override del YAML del personatge + override de sala). // Només té efecte si `light_flicker` és true. inline static Lighting::FlickerConfig flicker_params{}; // 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; } // 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]] static auto getLightRadius() -> int { return light_radius; } [[nodiscard]] static auto getLightFlicker() -> bool { return light_flicker; } [[nodiscard]] static auto getLightColorR() -> std::uint8_t { return light_color_r; } [[nodiscard]] static auto getLightColorG() -> std::uint8_t { return light_color_g; } [[nodiscard]] static auto getLightColorB() -> std::uint8_t { return light_color_b; } [[nodiscard]] static auto getLightOffsetX() -> int { return light_offset_x; } [[nodiscard]] static auto getLightOffsetY() -> int { return light_offset_y; } [[nodiscard]] static auto getFlickerParams() -> const Lighting::FlickerConfig& { return 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; 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_; } // 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* 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(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. `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; // 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* 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). 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/.pal`). Mateix patró que els altres // readers — `loadFrom` el crida al final. static void readImmunityBlock(const fkyaml::node& yaml); // Llig el bloc opcional `light: { radius: N, flicker: bool }` del // YAML i pobla `light_radius` / `light_flicker`. Si manca, els // valors queden a 0 / false (jugador sense llum). static void readLightBlock(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; // 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 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}; };