From 2aef925286c5617b892b09dbb7165bbd370a8b5f Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Wed, 13 May 2026 21:40:02 +0200 Subject: [PATCH] refactor(player): config a membres d'instancia (P1/P2 YAMLs independents) --- source/game/entities/player.cpp | 574 ++++++++++++++---------------- source/game/entities/player.hpp | 392 ++++++++++---------- source/game/scenes/game_scene.cpp | 133 +++---- source/game/scenes/game_scene.hpp | 8 +- 4 files changed, 516 insertions(+), 591 deletions(-) diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index c743632..67a12f3 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -37,19 +37,6 @@ namespace { // repòs i no contribueix a la velocitat. constexpr Sint16 STICK_DEADZONE = 12000; - // Llegeix la mida del frame des del bloc `sprite: { path, size: [w, h] }` - // (mateix format que la resta de plantilles d'entitat, vegeu - // AnimatedSprite). Extracció per mantenir `Player::loadFrom` sota el - // llindar de complexitat cognitiva (25). - void readSpriteSize(const fkyaml::node& yaml, const std::string& yaml_path, int& w, int& h) { - if (!yaml.contains("sprite") || !yaml["sprite"].contains("size")) { - throw std::runtime_error("Player: manca `sprite.size` a " + yaml_path); - } - const auto& size_node = yaml["sprite"]["size"]; - w = size_node[0].get_value(); - h = size_node[1].get_value(); - } - // Acumula la contribució del mando assignat al jugador (cruceta + // stick esquerre) sobre els acumuladors de velocitat. Extracció // d'`update()` per a mantenir la complexitat cognitiva sota llindar @@ -73,15 +60,156 @@ namespace { } // namespace -Player::TurnConfig Player::turn_config; -Player::InertiaConfig Player::inertia_config; -Player::JumpConfig Player::jump_config; -Player::HurtConfig Player::hurt_config; -Player::WhipConfig Player::whip_config; -Player::DeathConfig Player::death_config; -Player::ImmunityConfig Player::immunity_config; +namespace { -void Player::loadFrom(const std::string& yaml_path) { + // ---- Helpers de lectura per sub-blocs del YAML del personatge ---- + // Cada helper rep el bloc YAML root i un `Player::Config&` a poblar. + // Camps absents respecten els defaults del Config (no reinicialitzen + // a 0 — la Config ja ve construïda amb els valors default abans). + + void readSpriteSizeInto(const fkyaml::node& yaml, const std::string& yaml_path, Player::Config& out) { + if (!yaml.contains("sprite") || !yaml["sprite"].contains("size")) { + throw std::runtime_error("Player: manca `sprite.size` a " + yaml_path); + } + const auto& size_node = yaml["sprite"]["size"]; + out.sprite_w = size_node[0].get_value(); + out.sprite_h = size_node[1].get_value(); + } + + void readCollisionInto(const fkyaml::node& yaml, const std::string& yaml_path, Player::Config& out) { + if (!yaml.contains("collision")) { + throw std::runtime_error("Player: manca bloc `collision:` a " + yaml_path); + } + const auto& c = yaml["collision"]; + out.collision_size = c["size"].get_value(); + out.base_size = c["base_size"].get_value(); + + // Col·lisió centrada en el quadrat inferior del sprite. + out.collision_padding = (out.collision_size - out.base_size) / 2; + out.collision_x_offset = -out.collision_padding; + out.collision_y_offset = (out.sprite_h - out.base_size) - out.collision_padding; + + // Hit-box opcional (actor-actor): default = caixa de col·lisió. + out.hit_size = c.contains("hit_size") ? c["hit_size"].get_value() : out.collision_size; + const int HIT_INSET = (out.collision_size - out.hit_size) / 2; + out.hit_x_offset = out.collision_x_offset + HIT_INSET; + out.hit_y_offset = out.collision_y_offset + HIT_INSET; + + out.body_height = c.contains("height") ? c["height"].get_value() : 0; + } + + void readInertiaInto(const fkyaml::node& yaml, Player::Config& out) { + if (!yaml.contains("inertia")) { return; } + const auto& i = yaml["inertia"]; + if (i.contains("brake_time_ms")) { out.inertia.brake_time_ms = i["brake_time_ms"].get_value(); } + if (i.contains("threshold_ms")) { out.inertia.threshold_ms = i["threshold_ms"].get_value(); } + } + + void readTurnInto(const fkyaml::node& yaml, Player::Config& out) { + if (!yaml.contains("turn")) { return; } + const auto& t = yaml["turn"]; + if (t.contains("duration_ms")) { out.turn.duration_ms = t["duration_ms"].get_value(); } + if (!t.contains("transitions")) { return; } + const auto& tr = t["transitions"]; + if (tr.contains("west_to_east")) { out.turn.w_to_e = Player::cardinalFromString(tr["west_to_east"].get_value()); } + if (tr.contains("east_to_west")) { out.turn.e_to_w = Player::cardinalFromString(tr["east_to_west"].get_value()); } + if (tr.contains("north_to_south")) { out.turn.n_to_s = Player::cardinalFromString(tr["north_to_south"].get_value()); } + if (tr.contains("south_to_north")) { out.turn.s_to_n = Player::cardinalFromString(tr["south_to_north"].get_value()); } + } + + void readJumpInto(const fkyaml::node& yaml, Player::Config& out) { + if (!yaml.contains("jump")) { return; } + const auto& j = yaml["jump"]; + if (j.contains("impulse")) { out.jump.impulse_px_per_s = j["impulse"].get_value(); } + if (j.contains("gravity")) { out.jump.gravity_px_per_s2 = j["gravity"].get_value(); } + if (j.contains("landing_stun_ms")) { out.jump.landing_stun_ms = j["landing_stun_ms"].get_value(); } + if (j.contains("sound")) { + out.jump.sound_key = j["sound"].get_value(); + Resource::Cache::get().loadSound(out.jump.sound_key, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + out.jump.sound_key); + } + if (j.contains("land_sound")) { + out.jump.land_sound_key = j["land_sound"].get_value(); + Resource::Cache::get().loadSound(out.jump.land_sound_key, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + out.jump.land_sound_key); + } + } + + void readHurtInto(const fkyaml::node& yaml, Player::Config& out) { + if (!yaml.contains("hurt")) { return; } + const auto& h = yaml["hurt"]; + if (h.contains("speed")) { out.hurt.initial_speed_px_per_s = h["speed"].get_value(); } + if (h.contains("brake_time_ms")) { out.hurt.brake_time_ms = h["brake_time_ms"].get_value(); } + if (h.contains("sound")) { + out.hurt.sound_key = h["sound"].get_value(); + Resource::Cache::get().loadSound(out.hurt.sound_key, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + out.hurt.sound_key); + } + } + + void readWhipInto(const fkyaml::node& yaml, Player::Config& out) { + if (!yaml.contains("whip")) { return; } + const auto& w = yaml["whip"]; + if (w.contains("range_tiles")) { out.whip.range_tiles = w["range_tiles"].get_value(); } + if (w.contains("swing_ms")) { out.whip.swing_ms = w["swing_ms"].get_value(); } + if (w.contains("cooldown_ms")) { out.whip.cooldown_ms = w["cooldown_ms"].get_value(); } + if (w.contains("damage")) { out.whip.damage = w["damage"].get_value(); } + if (w.contains("sound")) { + out.whip.sound_key = w["sound"].get_value(); + Resource::Cache::get().loadSound(out.whip.sound_key, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + out.whip.sound_key); + } + } + + void readDeathInto(const fkyaml::node& yaml, Player::Config& out) { + if (!yaml.contains("death")) { return; } + const auto& d = yaml["death"]; + if (d.contains("respawn_delay_ms")) { out.death.respawn_delay_ms = d["respawn_delay_ms"].get_value(); } + if (d.contains("lives")) { out.death.initial_lives = d["lives"].get_value(); } + } + + void readImmunityInto(const fkyaml::node& yaml, Player::Config& out) { + if (!yaml.contains("immunity")) { return; } + const auto& im = yaml["immunity"]; + if (im.contains("duration_ms")) { out.immunity.duration_ms = im["duration_ms"].get_value(); } + if (im.contains("blink_period_ms")) { out.immunity.blink_period_ms = im["blink_period_ms"].get_value(); } + if (im.contains("palette")) { + out.immunity.palette_key = im["palette"].get_value(); + Resource::Cache::get().loadPalette(out.immunity.palette_key, std::string{Defaults::Paths::PLAYER_DIR} + out.immunity.palette_key + ".pal"); + } + } + + void readLightInto(const fkyaml::node& yaml, Player::Config& out) { + // Punt de partida del flicker: el global de lighting.yaml. El bloc + // opcional `flicker_params:` del personatge sobreescriu camps concrets. + out.flicker_params = Lighting::flickerConfig(); + if (!yaml.contains("light")) { return; } + const auto& l = yaml["light"]; + if (l.contains("radius")) { out.light_radius = l["radius"].get_value(); } + if (l.contains("flicker")) { out.light_flicker = l["flicker"].get_value(); } + if (l.contains("color")) { + const auto& c = l["color"]; + if (c.is_sequence() && c.size() == 3) { + out.light_color_r = static_cast(std::clamp(c[0].get_value(), 0, 255)); + out.light_color_g = static_cast(std::clamp(c[1].get_value(), 0, 255)); + out.light_color_b = static_cast(std::clamp(c[2].get_value(), 0, 255)); + } + } + if (l.contains("offset")) { + const auto& o = l["offset"]; + if (o.is_sequence() && o.size() == 2) { + out.light_offset_x = o[0].get_value(); + out.light_offset_y = o[1].get_value(); + } + } + if (l.contains("flicker_params")) { + const auto& fp = l["flicker_params"]; + if (fp.contains("wave_amplitude_ratio")) { out.flicker_params.wave_amplitude_ratio = fp["wave_amplitude_ratio"].get_value(); } + if (fp.contains("wave_frequency_hz")) { out.flicker_params.wave_frequency_hz = fp["wave_frequency_hz"].get_value(); } + if (fp.contains("noise_amplitude_ratio")) { out.flicker_params.noise_amplitude_ratio = fp["noise_amplitude_ratio"].get_value(); } + if (fp.contains("noise_frequency_hz")) { out.flicker_params.noise_frequency_hz = fp["noise_frequency_hz"].get_value(); } + } + } + +} // namespace + +auto Player::Config::loadFromYaml(const std::string& yaml_path) -> Player::Config { auto bytes = Resource::Helper::loadFile(yaml_path); if (bytes.empty()) { throw std::runtime_error("Player: no s'ha pogut obrir " + yaml_path); @@ -90,208 +218,51 @@ void Player::loadFrom(const std::string& yaml_path) { const auto* end = begin + bytes.size(); auto yaml = fkyaml::node::deserialize(begin, end); - readSpriteSize(yaml, yaml_path, sprite_w, sprite_h); - - readCollisionBlock(yaml, yaml_path); - - // Bloc opcional `inertia:` — si manca, `brake_time_ms` queda a 0 i - // la inèrcia de frenada no s'activa (parada instantània com abans). - inertia_config = InertiaConfig{}; - if (yaml.contains("inertia")) { - const auto& i = yaml["inertia"]; - if (i.contains("brake_time_ms")) { inertia_config.brake_time_ms = i["brake_time_ms"].get_value(); } - if (i.contains("threshold_ms")) { inertia_config.threshold_ms = i["threshold_ms"].get_value(); } - } - - // Bloc opcional `turn:` — si manca, `duration_ms` queda a 0 i la - // mecànica del gir de 180° no s'activa (canvis de direcció instantanis). - turn_config = TurnConfig{}; - if (yaml.contains("turn")) { - const auto& t = yaml["turn"]; - if (t.contains("duration_ms")) { turn_config.duration_ms = t["duration_ms"].get_value(); } - if (t.contains("transitions")) { - const auto& tr = t["transitions"]; - if (tr.contains("west_to_east")) { turn_config.w_to_e = cardinalFromString(tr["west_to_east"].get_value()); } - if (tr.contains("east_to_west")) { turn_config.e_to_w = cardinalFromString(tr["east_to_west"].get_value()); } - if (tr.contains("north_to_south")) { turn_config.n_to_s = cardinalFromString(tr["north_to_south"].get_value()); } - if (tr.contains("south_to_north")) { turn_config.s_to_n = cardinalFromString(tr["south_to_north"].get_value()); } - } - } - - // Bloc opcional `jump:` — si manca, `impulse_px_per_s` queda a 0 i el - // botó de salt no fa res. Extret a un mètode estàtic per acotar la - // complexitat cognitiva de `loadFrom` sota el llindar de clang-tidy. - readJumpBlock(yaml); - - // Bloc opcional `hurt:` — estat post-impacte. Si manca o el speed - // és 0, l'estat HURT mai s'activa (cas degenerat). - readHurtBlock(yaml); - - // Bloc opcional `whip:` — paràmetres del látigo. Si manca, el - // jugador no atacarà efectivament (ATTACKING dura 0 ms i el flanc - // d'input es perd). - readWhipBlock(yaml); - - // Bloc opcional `death:` — pausa de respawn i comptador inicial de - // vides. Si manca, el respawn és immediat i les vides queden a 3 - // per defecte (no és un valor crític: avui no hi ha game over). - readDeathBlock(yaml); - - // Bloc opcional `immunity:` — finestra de gràcia post-spawn. Si - // `duration_ms` és 0 (o el bloc manca) la mecànica queda - // desactivada i els hazards toquen al primer frame, com abans. - readImmunityBlock(yaml); - - // Bloc opcional `light:` — radi i flicker. Extret a un mètode per - // mantenir `loadFrom` sota el llindar de complexitat de clang-tidy. - readLightBlock(yaml); + Player::Config out; + readSpriteSizeInto(yaml, yaml_path, out); + readCollisionInto(yaml, yaml_path, out); + readInertiaInto(yaml, out); + readTurnInto(yaml, out); + readJumpInto(yaml, out); + readHurtInto(yaml, out); + readWhipInto(yaml, out); + readDeathInto(yaml, out); + readImmunityInto(yaml, out); + readLightInto(yaml, out); + return out; } -void Player::readLightBlock(const fkyaml::node& yaml) { - light_radius = 0; - light_flicker = false; - light_color_r = 255; - light_color_g = 255; - light_color_b = 255; - light_offset_x = 0; - light_offset_y = 0; - // Punt de partida del flicker: el global de lighting.yaml. El bloc - // opcional `flicker_params:` del personatge sobreescriu camps concrets. - flicker_params = Lighting::flickerConfig(); - if (!yaml.contains("light")) { return; } - const auto& l = yaml["light"]; - if (l.contains("radius")) { light_radius = l["radius"].get_value(); } - if (l.contains("flicker")) { light_flicker = l["flicker"].get_value(); } - // `color: [r, g, b]` opcional, bytes 0..255. Si manca, blanc (sense - // tint) — comportament idèntic al mode subtractive de hui. - if (l.contains("color")) { - const auto& c = l["color"]; - if (c.is_sequence() && c.size() == 3) { - light_color_r = static_cast(std::clamp(c[0].get_value(), 0, 255)); - light_color_g = static_cast(std::clamp(c[1].get_value(), 0, 255)); - light_color_b = static_cast(std::clamp(c[2].get_value(), 0, 255)); - } +void Player::Config::applyRoomLightOverride() { + // Override de sala per al jugador (bloc `lighting.emitters.player` + // del YAML del mapa). Si la sala no en declara, els valors base del + // YAML del personatge es conserven. Si la sala desactiva + // (`players: false`), `light_radius = 0` (no emet). + const auto* room = Map::getRoomEmitterOverride("player"); + if (room == nullptr) { return; } + if (!room->enabled) { + light_radius = 0; + light_flicker = false; + return; } - // `offset: [x, y]` opcional, píxels enters. Default (0, 0) → centre - // del sprite. Y creix cap avall, així que `y: -3` puja la llum 3 px. - if (l.contains("offset")) { - const auto& o = l["offset"]; - if (o.is_sequence() && o.size() == 2) { - light_offset_x = o[0].get_value(); - light_offset_y = o[1].get_value(); - } + if (room->radius.has_value()) { light_radius = *room->radius; } + if (room->flicker.has_value()) { light_flicker = *room->flicker; } + if (room->color.has_value()) { + light_color_r = (*room->color)[0]; + light_color_g = (*room->color)[1]; + light_color_b = (*room->color)[2]; } - // `flicker_params: { ... }` opcional. Qualsevol dels 4 camps absent - // conserva el valor heretat del global (vegeu inicialització a sobre). - if (l.contains("flicker_params")) { - const auto& fp = l["flicker_params"]; - if (fp.contains("wave_amplitude_ratio")) { flicker_params.wave_amplitude_ratio = fp["wave_amplitude_ratio"].get_value(); } - if (fp.contains("wave_frequency_hz")) { flicker_params.wave_frequency_hz = fp["wave_frequency_hz"].get_value(); } - if (fp.contains("noise_amplitude_ratio")) { flicker_params.noise_amplitude_ratio = fp["noise_amplitude_ratio"].get_value(); } - if (fp.contains("noise_frequency_hz")) { flicker_params.noise_frequency_hz = fp["noise_frequency_hz"].get_value(); } + if (room->offset.has_value()) { + light_offset_x = (*room->offset)[0]; + light_offset_y = (*room->offset)[1]; } + if (room->flicker_wave_amplitude_ratio.has_value()) { flicker_params.wave_amplitude_ratio = *room->flicker_wave_amplitude_ratio; } + if (room->flicker_wave_frequency_hz.has_value()) { flicker_params.wave_frequency_hz = *room->flicker_wave_frequency_hz; } + if (room->flicker_noise_amplitude_ratio.has_value()) { flicker_params.noise_amplitude_ratio = *room->flicker_noise_amplitude_ratio; } + if (room->flicker_noise_frequency_hz.has_value()) { flicker_params.noise_frequency_hz = *room->flicker_noise_frequency_hz; } } -void Player::readCollisionBlock(const fkyaml::node& yaml, const std::string& yaml_path) { - if (!yaml.contains("collision")) { - throw std::runtime_error("Player: manca bloc `collision:` a " + yaml_path); - } - const auto& c = yaml["collision"]; - collision_size = c["size"].get_value(); - base_size = c["base_size"].get_value(); - - // Col·lisió centrada en el quadrat inferior del sprite. Si la caixa és - // més gran que la base (cas habitual, 16 vs 12) el padding és simètric - // a cada costat; si són iguals, padding=0 i la caixa coincideix amb la - // base sense offsets. - collision_padding = (collision_size - base_size) / 2; - collision_x_offset = -collision_padding; - collision_y_offset = (sprite_h - base_size) - collision_padding; - - // Hit-box opcional (actor-actor). Si no hi és, s'iguala a la base - // per preservar compatibilitat amb YAMLs antics. Concèntrica amb la - // caixa de col·lisió: offset interior = (collision_size - hit_size) / 2. - hit_size = c.contains("hit_size") ? c["hit_size"].get_value() : collision_size; - const int HIT_INSET = (collision_size - hit_size) / 2; - hit_x_offset = collision_x_offset + HIT_INSET; - hit_y_offset = collision_y_offset + HIT_INSET; - - // Alçada del cos, llegida del mateix bloc per coherència amb la - // resta d'entitats (mummy, sarcophagus, etc.). 0 = sense filtre per - // altura — el salt no esquiva l'altre jugador. - body_height = c.contains("height") ? c["height"].get_value() : 0; -} - -void Player::readJumpBlock(const fkyaml::node& yaml) { - jump_config = JumpConfig{}; - if (!yaml.contains("jump")) { return; } - const auto& j = yaml["jump"]; - if (j.contains("impulse")) { jump_config.impulse_px_per_s = j["impulse"].get_value(); } - if (j.contains("gravity")) { jump_config.gravity_px_per_s2 = j["gravity"].get_value(); } - if (j.contains("landing_stun_ms")) { jump_config.landing_stun_ms = j["landing_stun_ms"].get_value(); } - // Sons opcionals: el nom del fitxer (relatiu a Defaults::Paths::AUDIO_SOUNDS_DIR) - // val tant de clau al Resource::Cache com de basename per a la càrrega. - // Si el camp manca, la clau queda buida i `playSound` no es crida. - if (j.contains("sound")) { - jump_config.sound_key = j["sound"].get_value(); - Resource::Cache::get().loadSound(jump_config.sound_key, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + jump_config.sound_key); - } - if (j.contains("land_sound")) { - jump_config.land_sound_key = j["land_sound"].get_value(); - Resource::Cache::get().loadSound(jump_config.land_sound_key, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + jump_config.land_sound_key); - } -} - -void Player::readHurtBlock(const fkyaml::node& yaml) { - hurt_config = HurtConfig{}; - if (!yaml.contains("hurt")) { return; } - const auto& h = yaml["hurt"]; - if (h.contains("speed")) { hurt_config.initial_speed_px_per_s = h["speed"].get_value(); } - if (h.contains("brake_time_ms")) { hurt_config.brake_time_ms = h["brake_time_ms"].get_value(); } - // So d'entrada a HURT — mateix patró que `jump.sound` i `whip.sound`. - if (h.contains("sound")) { - hurt_config.sound_key = h["sound"].get_value(); - Resource::Cache::get().loadSound(hurt_config.sound_key, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + hurt_config.sound_key); - } -} - -void Player::readDeathBlock(const fkyaml::node& yaml) { - death_config = DeathConfig{}; - if (!yaml.contains("death")) { return; } - const auto& d = yaml["death"]; - if (d.contains("respawn_delay_ms")) { death_config.respawn_delay_ms = d["respawn_delay_ms"].get_value(); } - if (d.contains("lives")) { death_config.initial_lives = d["lives"].get_value(); } -} - -void Player::readImmunityBlock(const fkyaml::node& yaml) { - immunity_config = ImmunityConfig{}; - if (!yaml.contains("immunity")) { return; } - const auto& im = yaml["immunity"]; - if (im.contains("duration_ms")) { immunity_config.duration_ms = im["duration_ms"].get_value(); } - if (im.contains("blink_period_ms")) { immunity_config.blink_period_ms = im["blink_period_ms"].get_value(); } - // Paleta del blink: el basename val tant de clau al Resource::Cache - // com de path (`Defaults::Paths::PLAYER_DIR + key + .pal`). Mateix patró - // que els sons. - if (im.contains("palette")) { - immunity_config.palette_key = im["palette"].get_value(); - Resource::Cache::get().loadPalette(immunity_config.palette_key, std::string{Defaults::Paths::PLAYER_DIR} + immunity_config.palette_key + ".pal"); - } -} - -void Player::readWhipBlock(const fkyaml::node& yaml) { - whip_config = WhipConfig{}; - if (!yaml.contains("whip")) { return; } - const auto& w = yaml["whip"]; - if (w.contains("range_tiles")) { whip_config.range_tiles = w["range_tiles"].get_value(); } - if (w.contains("swing_ms")) { whip_config.swing_ms = w["swing_ms"].get_value(); } - if (w.contains("cooldown_ms")) { whip_config.cooldown_ms = w["cooldown_ms"].get_value(); } - if (w.contains("damage")) { whip_config.damage = w["damage"].get_value(); } - // So del swing: mateix patró que `jump.sound` — basename val de - // clau al cache i de path. Si manca, queda buit i no es dispara. - if (w.contains("sound")) { - whip_config.sound_key = w["sound"].get_value(); - Resource::Cache::get().loadSound(whip_config.sound_key, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + whip_config.sound_key); - } +void Player::setConfig(Config cfg) { + cfg_ = std::move(cfg); } void Player::applyCardinal(Cardinal c) { @@ -350,16 +321,16 @@ auto Player::opposite(Cardinal c) -> Cardinal { return Cardinal::NONE; } -auto Player::intermediateFor(Cardinal target) -> Cardinal { +auto Player::intermediateFor(Cardinal target) const -> Cardinal { switch (target) { case Cardinal::EAST: - return turn_config.w_to_e; + return cfg_.turn.w_to_e; case Cardinal::WEST: - return turn_config.e_to_w; + return cfg_.turn.e_to_w; case Cardinal::SOUTH: - return turn_config.n_to_s; + return cfg_.turn.n_to_s; case Cardinal::NORTH: - return turn_config.s_to_n; + return cfg_.turn.s_to_n; case Cardinal::NONE: return Cardinal::NONE; } @@ -397,11 +368,11 @@ void Player::updateFacingAndTurn(float delta_time, float vx, float vy) { // a la prèvia. Requereix també que el bloc `turn:` del YAML // estiga activat (`duration_ms > 0`). const bool TRIGGER_TURN = - (turn_config.duration_ms > 0) && INPUT_CARD != Cardinal::NONE && last_input_cardinal_ != Cardinal::NONE && INPUT_CARD == opposite(last_input_cardinal_); + (cfg_.turn.duration_ms > 0) && INPUT_CARD != Cardinal::NONE && last_input_cardinal_ != Cardinal::NONE && INPUT_CARD == opposite(last_input_cardinal_); if (TRIGGER_TURN) { pending_cardinal_ = INPUT_CARD; intermediate_cardinal_ = intermediateFor(INPUT_CARD); - turning_remaining_ms_ = static_cast(turn_config.duration_ms); + turning_remaining_ms_ = static_cast(cfg_.turn.duration_ms); applyCardinal(intermediate_cardinal_); } else if (vy < 0.0F) { // Tria de facing amb prevalença vertical: si hi ha component @@ -425,8 +396,9 @@ void Player::updateFacingAndTurn(float delta_time, float vx, float vy) { } } -Player::Player(const std::string& anim_key, int id, float x, float y, const Controls& controls, const Map::Info& map_info, int paint_tile_index) +Player::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) : AnimatedSprite(Resource::Cache::get().getAnimationData(anim_key)), + cfg_(std::move(cfg)), id_(id), controls_(controls), map_(map_info), @@ -440,7 +412,7 @@ Player::Player(const std::string& anim_key, int id, float x, float y, const Cont setCurrentAnimation("stand_south"); // Comptador d'intents inicialitzat amb el valor del YAML (default 3 // si el bloc `death:` manca). Fora del cicle DYING no toca a ningú. - lives_remaining_ = death_config.initial_lives; + lives_remaining_ = cfg_.death.initial_lives; // Spawn point inicial: la posició del ctor val per defecte; el // GameScene el reescriu via `setSpawnPoint` quan canvia de sala. spawn_x_ = x; @@ -483,7 +455,7 @@ auto Player::collidesWithWorld(const SDL_FRect& box) const -> bool { // travessa net del caminant; ningú actua de "muro" durant el // vol, ni a la pujada ni a la baixada. // - Tots dos en l'aire → col·lisió només si els rangs verticals - // [jump_z, jump_z + body_height] solapen. Així permetem el xoc + // [jump_z, jump_z + cfg_.body_height] solapen. Així permetem el xoc // mid-aire quan tots dos van un contra l'altre i estan a la // mateixa franja d'alçada. const SDL_FRect PROPOSED_HIT = hitBoxFromBase(box); @@ -502,7 +474,7 @@ auto Player::collidesWithWorld(const SDL_FRect& box) const -> bool { // Aquí els dos estats coincideixen. Si tots dos volen, el filtre // per altura mira que els rangs verticals encara solapin; si no, // estan a prou distància en Y "real" i no es toquen. - if (MY_AIR && body_height > 0 && std::fabs(MY_Z - p->jump_z_) >= static_cast(body_height)) { + if (MY_AIR && cfg_.body_height > 0 && std::fabs(MY_Z - p->jump_z_) >= static_cast(cfg_.body_height)) { return false; } const auto OTHER = p->getHitBox(); @@ -510,7 +482,7 @@ auto Player::collidesWithWorld(const SDL_FRect& box) const -> bool { }); } -void Player::integrateAxis(float& phys, float input, float delta_time, float motion_time_ms, bool any_input) { +void Player::integrateAxis(float& phys, float input, float delta_time, float motion_time_ms, bool any_input) const { // Acceleració instantània: mentre hi ha input, la velocitat salta a // ±SPEED sense transició. Cobreix també els canvis de direcció (un // coast cap a la dreta passa a -SPEED immediatament si es prem esquerra). @@ -528,12 +500,12 @@ void Player::integrateAxis(float& phys, float input, float delta_time, float mot phys = 0.0F; return; } - const bool INERTIA_ACTIVE = (inertia_config.brake_time_ms > 0) && (motion_time_ms >= static_cast(inertia_config.threshold_ms)); + const bool INERTIA_ACTIVE = (cfg_.inertia.brake_time_ms > 0) && (motion_time_ms >= static_cast(cfg_.inertia.threshold_ms)); if (!INERTIA_ACTIVE) { phys = 0.0F; return; } - const float DECEL = SPEED_PX_PER_S / (static_cast(inertia_config.brake_time_ms) / 1000.0F); + const float DECEL = SPEED_PX_PER_S / (static_cast(cfg_.inertia.brake_time_ms) / 1000.0F); const float STEP = DECEL * delta_time; if (std::fabs(phys) <= STEP) { phys = 0.0F; @@ -656,8 +628,8 @@ void Player::tick(float delta_time) { if (state_ == State::HURT) { (void)consumeJumpEdge(); (void)consumeAttackEdge(); - const float DECEL = (hurt_config.brake_time_ms > 0) - ? hurt_config.initial_speed_px_per_s / (static_cast(hurt_config.brake_time_ms) / 1000.0F) + const float DECEL = (cfg_.hurt.brake_time_ms > 0) + ? cfg_.hurt.initial_speed_px_per_s / (static_cast(cfg_.hurt.brake_time_ms) / 1000.0F) : 0.0F; const float STEP = DECEL * delta_time; auto decay = [STEP](float& v) { @@ -742,7 +714,7 @@ void Player::tick(float delta_time) { // doble per excés que perdre l'atac per un toc accidental al salt). // En entrar a ATTACKING, descartem el flanc del salt per no que // queda penjat per a quan es desbloque. - if (consumeAttackEdge() && whip_config.swing_ms > 0) { + if (consumeAttackEdge() && cfg_.whip.swing_ms > 0) { enterAttacking(currentFacingCardinal()); (void)consumeJumpEdge(); return; @@ -755,7 +727,7 @@ void Player::tick(float delta_time) { // ho gestionem deixant que el resolver vinga, i si en el moment del // despegue (consumeJumpEdge → startJump) ja estem AIRBORNE, l'altre // braç del switch no canvia res. - if (consumeJumpEdge() && jump_config.impulse_px_per_s > 0.0F) { + if (consumeJumpEdge() && cfg_.jump.impulse_px_per_s > 0.0F) { startJump(); } } @@ -831,7 +803,7 @@ auto Player::currentFacingCardinal() const -> Cardinal { void Player::enterAttacking(Cardinal facing) { state_ = State::ATTACKING; - attacking_remaining_ms_ = static_cast(whip_config.swing_ms + whip_config.cooldown_ms); + attacking_remaining_ms_ = static_cast(cfg_.whip.swing_ms + cfg_.whip.cooldown_ms); // Garantim que facing/flip i cardinal queden coherents amb la // direcció triada (per si l'atac s'ha disparat amb facing residual, @@ -843,8 +815,8 @@ void Player::enterAttacking(Cardinal facing) { // So del swing — disparat ací (no des del WhipAttack) perquè és el // jugador qui executa l'acció; si demà el spawn falla per qualsevol // motiu, el so del gest segueix sonant. - if (!whip_config.sound_key.empty() && Audio::get() != nullptr) { - Audio::get()->playSound(whip_config.sound_key, Audio::Group::GAME); + if (!cfg_.whip.sound_key.empty() && Audio::get() != nullptr) { + Audio::get()->playSound(cfg_.whip.sound_key, Audio::Group::GAME); } // Mapa de Cardinal → int al payload (vegeu `WhipSpawnPayload`). @@ -870,7 +842,7 @@ void Player::enterAttacking(Cardinal facing) { // en deriva tant la draw_rect (sprite del látigo, més gran) com la // damage_rect (al nivell dels peus, retallada per bloquejadors). if (bus_ != nullptr) { - const float RANGE_PX = whip_config.range_tiles * static_cast(Map::tile_size); + const float RANGE_PX = cfg_.whip.range_tiles * static_cast(Map::tile_size); bus_->publish("whip_spawn", WhipSpawnPayload{ .owner = this, .direction = dir_int, @@ -904,16 +876,16 @@ void Player::startJump() { state_ = State::AIRBORNE; jump_z_ = 0.0F; - jump_vz_ = jump_config.impulse_px_per_s; - if (!jump_config.sound_key.empty() && Audio::get() != nullptr) { - Audio::get()->playSound(jump_config.sound_key, Audio::Group::GAME); + jump_vz_ = cfg_.jump.impulse_px_per_s; + if (!cfg_.jump.sound_key.empty() && Audio::get() != nullptr) { + Audio::get()->playSound(cfg_.jump.sound_key, Audio::Group::GAME); } // Avisem al bus perquè el GameScene puga spawnejar el polvet de // despegue al peu del jugador. if (bus_ != nullptr) { bus_->publish(Event{ .name = "player_jumped", - .payload = WorldPos{.x = getPosX() + (static_cast(sprite_w) * 0.5F), .y = getGroundFeetY()}, + .payload = WorldPos{.x = getPosX() + (static_cast(cfg_.sprite_w) * 0.5F), .y = getGroundFeetY()}, .player_id = id_, }); } @@ -957,7 +929,7 @@ auto Player::chooseHurtDirection(Cardinal preferred) const -> Cardinal { // abans d'aquesta distància, l'usem; d'altra manera, busquem la // millor. const auto REQUIRED = static_cast( - 0.5F * hurt_config.initial_speed_px_per_s * (static_cast(hurt_config.brake_time_ms) / 1000.0F)); + 0.5F * cfg_.hurt.initial_speed_px_per_s * (static_cast(cfg_.hurt.brake_time_ms) / 1000.0F)); if (preferred != Cardinal::NONE) { const auto [DX, DY] = deltasFor(preferred); @@ -986,14 +958,14 @@ void Player::enterHurt(Cardinal preferred) { // Si la mecànica no està configurada (speed = 0), no fem res — el // comportament queda equivalent al d'abans, sense empenta. - if (hurt_config.initial_speed_px_per_s <= 0.0F || hurt_config.brake_time_ms <= 0) { return; } + if (cfg_.hurt.initial_speed_px_per_s <= 0.0F || cfg_.hurt.brake_time_ms <= 0) { return; } const Cardinal CHOSEN = chooseHurtDirection(preferred); // Activem l'estat i fixem la velocitat segons la direcció triada. state_ = State::HURT; - if (!hurt_config.sound_key.empty() && Audio::get() != nullptr) { - Audio::get()->playSound(hurt_config.sound_key, Audio::Group::GAME); + if (!cfg_.hurt.sound_key.empty() && Audio::get() != nullptr) { + Audio::get()->playSound(cfg_.hurt.sound_key, Audio::Group::GAME); } applyCardinal(CHOSEN); last_input_cardinal_ = CHOSEN; @@ -1001,7 +973,7 @@ void Player::enterHurt(Cardinal preferred) { pending_cardinal_ = Cardinal::NONE; intermediate_cardinal_ = Cardinal::NONE; being_pushed_ = false; - const float V = hurt_config.initial_speed_px_per_s; + const float V = cfg_.hurt.initial_speed_px_per_s; const auto [DX, DY] = deltasFor(CHOSEN); intended_vx_ = DX * V; intended_vy_ = DY * V; @@ -1012,7 +984,7 @@ void Player::enterHurt(Cardinal preferred) { auto Player::integrateJump(float delta_time) -> bool { if (state_ != State::AIRBORNE) { return false; } - jump_vz_ -= jump_config.gravity_px_per_s2 * delta_time; + jump_vz_ -= cfg_.jump.gravity_px_per_s2 * delta_time; jump_z_ += jump_vz_ * delta_time; // Caiguda per agujero: el sòl ja no és present sota el jugador, així // que NO clampem `jump_z_` a 0. Deixem baixar a negatius (visualment @@ -1051,22 +1023,22 @@ auto Player::integrateJump(float delta_time) -> bool { jump_z_ = 0.0F; jump_vz_ = 0.0F; state_ = State::LANDING_STUN; - landing_stun_remaining_ms_ = static_cast(jump_config.landing_stun_ms); - if (!jump_config.land_sound_key.empty() && Audio::get() != nullptr) { + landing_stun_remaining_ms_ = static_cast(cfg_.jump.landing_stun_ms); + if (!cfg_.jump.land_sound_key.empty() && Audio::get() != nullptr) { // Consumeix el preset armat per GameScene al spawn aeri // d'INTRO (single-shot): el primer LAND el sona reverberat, // els salts següents cauen al camí sec. if (next_land_reverb_preset_.empty()) { - Audio::get()->playSound(jump_config.land_sound_key, Audio::Group::GAME); + Audio::get()->playSound(cfg_.jump.land_sound_key, Audio::Group::GAME); } else { - Audio::get()->playSoundWithReverb(jump_config.land_sound_key, next_land_reverb_preset_, Audio::Group::GAME); + Audio::get()->playSoundWithReverb(cfg_.jump.land_sound_key, next_land_reverb_preset_, Audio::Group::GAME); next_land_reverb_preset_.clear(); } } if (bus_ != nullptr) { bus_->publish(Event{ .name = "player_landed", - .payload = WorldPos{.x = getPosX() + (static_cast(sprite_w) * 0.5F), .y = getGroundFeetY()}, + .payload = WorldPos{.x = getPosX() + (static_cast(cfg_.sprite_w) * 0.5F), .y = getGroundFeetY()}, .player_id = id_, }); } @@ -1295,8 +1267,8 @@ void Player::enterDying(DeathCause cause) { // So del cop letal: reaprofitem `hurt.wav` (ja precarregat per // `readHurtBlock`). El bloc `death:` no té camp de so propi — // semànticament l'impacte mortal és la versió forta de `hurt`. - if (!hurt_config.sound_key.empty() && Audio::get() != nullptr) { - Audio::get()->playSound(hurt_config.sound_key, Audio::Group::GAME); + if (!cfg_.hurt.sound_key.empty() && Audio::get() != nullptr) { + Audio::get()->playSound(cfg_.hurt.sound_key, Audio::Group::GAME); } } @@ -1309,7 +1281,7 @@ void Player::applyDyingMovement(float delta_time) { animate(delta_time); if (!death_animation_complete_ && animationIsCompleted()) { death_animation_complete_ = true; - respawn_remaining_ms_ = static_cast(death_config.respawn_delay_ms); + respawn_remaining_ms_ = static_cast(cfg_.death.respawn_delay_ms); } if (death_animation_complete_) { respawn_remaining_ms_ -= delta_time * 1000.0F; @@ -1320,7 +1292,7 @@ void Player::applyDyingMovement(float delta_time) { } void Player::resetForJoin() { - lives_remaining_ = death_config.initial_lives; + lives_remaining_ = cfg_.death.initial_lives; score_ = 0; treasures_ = 0; // Publica l'estat inicial perquè el HUD reompla el bloc en el moment @@ -1489,12 +1461,12 @@ void Player::render() { // swap independentment. const bool BLINK_ON = immunity_remaining_ms_ > 0.0F && - immunity_config.blink_period_ms > 0 && - !immunity_config.palette_key.empty() && + cfg_.immunity.blink_period_ms > 0 && + !cfg_.immunity.palette_key.empty() && surface_ != nullptr && - (static_cast(immunity_remaining_ms_) / immunity_config.blink_period_ms) % 2 == 0; + (static_cast(immunity_remaining_ms_) / cfg_.immunity.blink_period_ms) % 2 == 0; if (BLINK_ON) { - surface_->setPalette(Resource::Cache::get().getPalette(immunity_config.palette_key)); + surface_->setPalette(Resource::Cache::get().getPalette(cfg_.immunity.palette_key)); } if (jump_z_ == 0.0F) { @@ -1517,8 +1489,8 @@ auto Player::getLightCenter() const -> SDL_FPoint { // que la llum "salti" amb el jugador en lloc de quedar-se al sòl. // Sumem també l'offset estàtic configurat al YAML del personatge. return { - .x = getPosX() + (static_cast(sprite_w) * 0.5F) + static_cast(light_offset_x), - .y = getPosY() - jump_z_ + (static_cast(sprite_h) * 0.5F) + static_cast(light_offset_y), + .x = getPosX() + (static_cast(cfg_.sprite_w) * 0.5F) + static_cast(cfg_.light_offset_x), + .y = getPosY() - jump_z_ + (static_cast(cfg_.sprite_h) * 0.5F) + static_cast(cfg_.light_offset_y), }; } @@ -1533,14 +1505,14 @@ void Player::renderShadow() const { if (falling_through_hole_) { return; } // `height_factor`: 0 al terra → fotograma més gran, 1 al pic → més - // petit. Si el `jump_config` té un divisor zero (cas degenerat) caem + // petit. Si el `cfg_.jump` té un divisor zero (cas degenerat) caem // a 0 per no propagar nan al renderer. - const auto PEAK = (jump_config.gravity_px_per_s2 > 0.0F) - ? (jump_config.impulse_px_per_s * jump_config.impulse_px_per_s) / (2.0F * jump_config.gravity_px_per_s2) + const auto PEAK = (cfg_.jump.gravity_px_per_s2 > 0.0F) + ? (cfg_.jump.impulse_px_per_s * cfg_.jump.impulse_px_per_s) / (2.0F * cfg_.jump.gravity_px_per_s2) : 0.0F; const float HEIGHT_FACTOR = (PEAK > 0.0F) ? std::clamp(jump_z_ / PEAK, 0.0F, 1.0F) : 0.0F; - const auto CENTER_X = static_cast(getPosX() + (static_cast(sprite_w) * 0.5F)); + const auto CENTER_X = static_cast(getPosX() + (static_cast(cfg_.sprite_w) * 0.5F)); const auto FEET_Y = static_cast(getGroundFeetY()); Effects::Shadow::render(CENTER_X, FEET_Y, HEIGHT_FACTOR); } @@ -1590,7 +1562,7 @@ auto Player::statePrefix(State state, bool moving, bool in_attack_swing) -> cons } auto Player::animationName(bool moving) const -> std::string { - const bool IN_ATTACK_SWING = (attacking_remaining_ms_ > static_cast(whip_config.cooldown_ms)); + const bool IN_ATTACK_SWING = (attacking_remaining_ms_ > static_cast(cfg_.whip.cooldown_ms)); return std::string(statePrefix(state_, moving, IN_ATTACK_SWING)) + facingSuffix(facing_); } @@ -1611,12 +1583,12 @@ auto Player::getCurrentTile() const -> std::pair { void Player::clampToPlayfield() { // Límits de la zona jugable: la caixa de col·lisió no pot eixir del // playfield (muros). El cap del sprite sí pot sobresortir per damunt. - const auto MIN_X = static_cast(map_.playfield_x - collision_x_offset); + const auto MIN_X = static_cast(map_.playfield_x - cfg_.collision_x_offset); const auto MAX_X = static_cast( - map_.playfield_x + map_.playfield_w - collision_x_offset - collision_size); - auto min_y = static_cast(map_.playfield_y - collision_y_offset); + map_.playfield_x + map_.playfield_w - cfg_.collision_x_offset - cfg_.collision_size); + auto min_y = static_cast(map_.playfield_y - cfg_.collision_y_offset); const auto MAX_Y = static_cast( - map_.playfield_y + map_.playfield_h - collision_y_offset - collision_size); + map_.playfield_y + map_.playfield_h - cfg_.collision_y_offset - cfg_.collision_size); // Passadís de sortida: si la base box queda enterament dins del rang // X del passadís de la porta, deixem que pugi al strip del mur trasser @@ -1624,12 +1596,12 @@ void Player::clampToPlayfield() { // playfield. La regla "enterament dins" exigeix alinear-se al passadís // abans de pujar — la base box (12 px) hi cap amb 4 px de marge. if (map_.door_corridor_w > 0) { - const float BASE_X = getPosX() + static_cast(collision_x_offset); - const float BASE_RIGHT = BASE_X + static_cast(collision_size); + const float BASE_X = getPosX() + static_cast(cfg_.collision_x_offset); + const float BASE_RIGHT = BASE_X + static_cast(cfg_.collision_size); const auto CORRIDOR_LEFT = static_cast(map_.door_corridor_x); const auto CORRIDOR_RIGHT = static_cast(map_.door_corridor_x + map_.door_corridor_w); if (BASE_X >= CORRIDOR_LEFT && BASE_RIGHT <= CORRIDOR_RIGHT) { - min_y = static_cast(map_.door_corridor_y - collision_y_offset); + min_y = static_cast(map_.door_corridor_y - cfg_.collision_y_offset); } } @@ -1674,7 +1646,7 @@ void Player::respawn(float x, float y) { // enemics ja estan apareixent i el jugador no ha de tindre gràcia). // Si el bloc `immunity:` manca al YAML, `duration_ms` és 0 i el timer // queda a 0 — sense gràcia, comportament idèntic al d'abans. - setImmunity(immunity_config.duration_ms); + setImmunity(cfg_.immunity.duration_ms); } void Player::disable() { @@ -1705,14 +1677,14 @@ void Player::enterHoleFall(float center_x, float center_y, float floor_y) { // cas i obligaria a hacks tipus "forçar GROUNDED abans de cridar". // Snap del centre de la hit-box a la coordenada central del forat. - // Resolem `pos = center - (hit_offset + hit_size/2)` per a cada eix: + // Resolem `pos = center - (hit_offset + cfg_.hit_size/2)` per a cada eix: // així el centre exacte de la caja de contacte queda alineat amb // (center_x, center_y), independentment de la geometria interna del // sprite. `setPosX/Y` és l'API canònica per teleportar el jugador // (la usen `respawn` i `spawnFromAir`). - const float HIT_HALF = static_cast(hit_size) * 0.5F; - setPosX(center_x - static_cast(hit_x_offset) - HIT_HALF); - setPosY(center_y - static_cast(hit_y_offset) - HIT_HALF); + const float HIT_HALF = static_cast(cfg_.hit_size) * 0.5F; + setPosX(center_x - static_cast(cfg_.hit_x_offset) - HIT_HALF); + setPosY(center_y - static_cast(cfg_.hit_y_offset) - HIT_HALF); // Frenem qualsevol inèrcia prèvia: la caiguda ha de ser estàtica al // centre del forat. @@ -1758,7 +1730,7 @@ void Player::spawnFromAir(float x, float y) { // visualment es pinta a `pos_.y - jump_z_`). Afegim un xicotet marge // perquè el primer frame ja siga clarament off-screen, no just al ras. constexpr float OFFSCREEN_MARGIN_PX = 8.0F; - const float SPAWN_HEIGHT = y + static_cast(sprite_h) + OFFSCREEN_MARGIN_PX; + const float SPAWN_HEIGHT = y + static_cast(cfg_.sprite_h) + OFFSCREEN_MARGIN_PX; state_ = State::AIRBORNE; jump_z_ = SPAWN_HEIGHT; @@ -1778,31 +1750,31 @@ void Player::spawnFromAir(float x, float y) { auto Player::getBaseBox() const -> SDL_FRect { return { - .x = getPosX() + static_cast(collision_x_offset), - .y = getPosY() + static_cast(collision_y_offset), - .w = static_cast(collision_size), - .h = static_cast(collision_size), + .x = getPosX() + static_cast(cfg_.collision_x_offset), + .y = getPosY() + static_cast(cfg_.collision_y_offset), + .w = static_cast(cfg_.collision_size), + .h = static_cast(cfg_.collision_size), }; } auto Player::getHitBox() const -> SDL_FRect { return { - .x = getPosX() + static_cast(hit_x_offset), - .y = getPosY() + static_cast(hit_y_offset), - .w = static_cast(hit_size), - .h = static_cast(hit_size), + .x = getPosX() + static_cast(cfg_.hit_x_offset), + .y = getPosY() + static_cast(cfg_.hit_y_offset), + .w = static_cast(cfg_.hit_size), + .h = static_cast(cfg_.hit_size), }; } -auto Player::hitBoxFromBase(const SDL_FRect& base) -> SDL_FRect { +auto Player::hitBoxFromBase(const SDL_FRect& base) const -> SDL_FRect { // Desplaçament intern respecte al cantó de la caixa base: mateixos // offsets que `getHitBox()` però partint d'una caixa arbitrària. - const int INSET = (collision_size - hit_size) / 2; + const int INSET = (cfg_.collision_size - cfg_.hit_size) / 2; return { .x = base.x + static_cast(INSET), .y = base.y + static_cast(INSET), - .w = static_cast(hit_size), - .h = static_cast(hit_size), + .w = static_cast(cfg_.hit_size), + .h = static_cast(cfg_.hit_size), }; } @@ -1811,7 +1783,7 @@ void Player::snapTowardsLane( float target_lane, float (Player::*get_pos)() const, void (Player::*set_pos)(float)) { - const auto THRESHOLD = static_cast(collision_size) / 2.0F; + const auto THRESHOLD = static_cast(cfg_.collision_size) / 2.0F; const float DELTA = target_lane - (this->*get_pos)(); if (std::abs(DELTA) <= 0.0F || std::abs(DELTA) > THRESHOLD) { return; } @@ -1911,10 +1883,10 @@ auto Player::wouldHitOuterWall(float dx, float dy) const -> bool { // corridor — encara no ho està, perquè el snap és justament qui // ha d'aliniar-la. En lloc d'això comprovem si la base box està // prou prop del centre del corridor com per a que el snap - // (mateix THRESHOLD = collision_size/2) puga completar-lo. + // (mateix THRESHOLD = cfg_.collision_size/2) puga completar-lo. if (Map::door_open && dy < 0.0F && map_.door_corridor_w > 0) { - const float CORRIDOR_TARGET = static_cast(map_.door_corridor_x) + (static_cast(map_.door_corridor_w - collision_size) / 2.0F); - const auto THRESHOLD = static_cast(collision_size) / 2.0F; + const float CORRIDOR_TARGET = static_cast(map_.door_corridor_x) + (static_cast(map_.door_corridor_w - cfg_.collision_size) / 2.0F); + const auto THRESHOLD = static_cast(cfg_.collision_size) / 2.0F; if (std::abs(CORRIDOR_TARGET - probe.x) <= THRESHOLD) { return false; } } return true; @@ -1928,7 +1900,7 @@ void Player::snapToHorizontalLane(float delta_time) { return; } - const auto BASE_Y = static_cast(map_.playfield_y - collision_y_offset); + const auto BASE_Y = static_cast(map_.playfield_y - cfg_.collision_y_offset); const auto PITCH_Y = static_cast(map_.pitch_y); const int IDX = std::clamp( static_cast(std::round((getPosY() - BASE_Y) / PITCH_Y)), @@ -1948,7 +1920,7 @@ void Player::snapToVerticalLane(float delta_time) { } // Candidat 1: lane regular del laberint. - const auto BASE_X = static_cast(map_.playfield_x - collision_x_offset); + const auto BASE_X = static_cast(map_.playfield_x - cfg_.collision_x_offset); const auto PITCH_X = static_cast(map_.pitch_x); const int IDX = std::clamp( static_cast(std::round((getPosX() - BASE_X) / PITCH_X)), @@ -1961,8 +1933,8 @@ void Player::snapToVerticalLane(float delta_time) { // forat per on no pot passar. float best_lane = LANE_REG; if (Map::door_open && map_.door_corridor_w > 0) { - const float CORRIDOR_CENTER = static_cast(map_.door_corridor_x) + (static_cast(map_.door_corridor_w - collision_size) / 2.0F); - const float LANE_DOOR = CORRIDOR_CENTER - static_cast(collision_x_offset); + const float CORRIDOR_CENTER = static_cast(map_.door_corridor_x) + (static_cast(map_.door_corridor_w - cfg_.collision_size) / 2.0F); + const float LANE_DOOR = CORRIDOR_CENTER - static_cast(cfg_.collision_x_offset); const float DELTA_REG = LANE_REG - getPosX(); const float DELTA_DOOR = LANE_DOOR - getPosX(); if (std::abs(DELTA_DOOR) < std::abs(DELTA_REG)) { best_lane = LANE_DOOR; } diff --git a/source/game/entities/player.hpp b/source/game/entities/player.hpp index 149b59c..a037a7e 100644 --- a/source/game/entities/player.hpp +++ b/source/game/entities/player.hpp @@ -55,6 +55,15 @@ class Player : public AnimatedSprite { 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, @@ -92,80 +101,172 @@ class Player : public AnimatedSprite { 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; + // 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" 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; + // 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 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; + // Alçada del cos en píxels (filtre de col·lisió per altura). + 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{}; + // ---- 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{}; - // Ha de cridar-se abans d'instanciar Player; GameScene ho fa al ctor. - static void loadFrom(const std::string& yaml_path); + // ---- Sub-structs de gameplay --------------------------------- - // 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; } + // 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]] 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; } + [[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 @@ -173,8 +274,6 @@ class Player : public AnimatedSprite { [[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)`. @@ -275,7 +374,7 @@ class Player : public AnimatedSprite { // 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); + return getPosY() + static_cast(cfg_.collision_y_offset + cfg_.collision_size); } // Alies semàntic: els peus del jugador al sòl. Coincideix amb @@ -484,7 +583,7 @@ class Player : public AnimatedSprite { // 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; + [[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 @@ -568,6 +667,11 @@ class Player : public AnimatedSprite { // 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_; @@ -639,26 +743,6 @@ class Player : public AnimatedSprite { 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 @@ -666,127 +750,7 @@ class Player : public AnimatedSprite { // 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); + 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 @@ -992,10 +956,16 @@ class Player : public AnimatedSprite { // 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; + 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}; diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index bc8d7ad..12fe1fb 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -171,9 +171,10 @@ namespace { // velocitats horitzontals i els deixem rebotar fins a aterrar // — els dos cauen en stun, com a càstig per abusar del salt. if (A_AIR) { - // cppcheck-suppress knownConditionTrueFalse - const auto BODY_H = static_cast(Player::body_height); - // cppcheck-suppress knownConditionTrueFalse + // Usem el body_height d'A (els dos haurien de ser idèntics en + // multijugador local actual; si en algun moment difereixen entre + // P1 i P2, prenem el del primer participant per consistència). + const auto BODY_H = static_cast(a->getBodyHeight()); if (BODY_H <= 0.0F) { return; } if (std::fabs(a->getJumpZ() - b->getJumpZ()) >= BODY_H) { return; } // Saltadors estàtics horitzontalment (cas degenerat: tots dos @@ -232,37 +233,14 @@ namespace { return out; } - // Override de sala per a la llum del jugador. Si el YAML de la sala - // declara `lighting.emitters.players`, substitueix els valors carregats - // per `Player::loadFrom`. Extret aquí per no inflar la complexitat - // cognitiva del ctor de GameScene (clang-tidy: cognitive-complexity). - // No s'aplica una "tercera capa" per-instància al jugador perquè no - // neix com a spawn del nivell; el dia d'un ítem "linterna" aquell - // sistema mutarà els mateixos camps en runtime — ortogonal a aquest - // override. - void applyRoomLightOverrideToPlayer() { - const auto* room = Map::getRoomEmitterOverride("player"); - if (room == nullptr) { return; } - if (!room->enabled) { - Player::light_radius = 0; - Player::light_flicker = false; - return; - } - if (room->radius.has_value()) { Player::light_radius = *room->radius; } - if (room->flicker.has_value()) { Player::light_flicker = *room->flicker; } - if (room->color.has_value()) { - Player::light_color_r = (*room->color)[0]; - Player::light_color_g = (*room->color)[1]; - Player::light_color_b = (*room->color)[2]; - } - if (room->offset.has_value()) { - Player::light_offset_x = (*room->offset)[0]; - Player::light_offset_y = (*room->offset)[1]; - } - if (room->flicker_wave_amplitude_ratio.has_value()) { Player::flicker_params.wave_amplitude_ratio = *room->flicker_wave_amplitude_ratio; } - if (room->flicker_wave_frequency_hz.has_value()) { Player::flicker_params.wave_frequency_hz = *room->flicker_wave_frequency_hz; } - if (room->flicker_noise_amplitude_ratio.has_value()) { Player::flicker_params.noise_amplitude_ratio = *room->flicker_noise_amplitude_ratio; } - if (room->flicker_noise_frequency_hz.has_value()) { Player::flicker_params.noise_frequency_hz = *room->flicker_noise_frequency_hz; } + // Carrega la config d'un jugador des del seu YAML i aplica l'override + // de sala (si el bloc `lighting.emitters.player` el reescriu). Helper + // compartit entre ctor i `loadRoom` per fer la mateixa cosa als dos + // moments: arrencada de partida i transició de sala. + auto loadPlayerConfigWithRoomOverride(const std::string& yaml_path) -> Player::Config { + auto cfg = Player::Config::loadFromYaml(yaml_path); + cfg.applyRoomLightOverride(); + return cfg; } } // namespace @@ -315,14 +293,14 @@ GameScene::GameScene(Config config) const std::string ANIM_P1 = load_player_assets(config_.player_sprite, config_.player_yaml); const std::string ANIM_P2 = load_player_assets(config_.player2_sprite, config_.player2_yaml); - // Geometria de col·lisió del jugador (caixa 16×16, base 12): carregada - // del mateix YAML que l'animació. Ha d'estar feta abans d'instanciar - // Player perquè el spawn consulta `collision_*`. Els membres de Player - // són `static inline`, compartits per tots els jugadors; carregar des - // del YAML de P1 és suficient (els dos personatges comparteixen - // geometria — morcus és un repaletat de sam). - Player::loadFrom(config_.player_yaml); - applyRoomLightOverrideToPlayer(); + // Geometria de col·lisió i tota la resta de config del jugador + // (caixa 16×16, base 12, jump, hurt, llum, etc.) carregada per + // instància des del YAML del personatge corresponent. Cada Player + // rep el seu `Config` al ctor (moure'l avall). L'override de sala + // s'aplica aquí abans de moure'l perquè el jugador ja arrenqui + // amb la llum correcta per a la sala actual. + Player::Config cfg_p1 = loadPlayerConfigWithRoomOverride(config_.player_yaml); + Player::Config cfg_p2 = loadPlayerConfigWithRoomOverride(config_.player2_yaml); // Carrega les entitats del nivell (antorxa, sarcòfags, porta, momia, // etc.). Requereix que Map::loadFrom ja s'hagi cridat (ho fa @@ -418,8 +396,9 @@ GameScene::GameScene(Config config) // Spawn lligat a la porta: tots els jugadors apareixen davant del // passadís de sortida. `computeSpawn` deriva les coordenades de // `Map::door_col` perquè canviar la columna de la porta al YAML - // moga automàticament l'spawn — no hi ha cap valor hardcoded. - const auto SPAWN = computeSpawn(); + // moga automàticament l'spawn — no hi ha cap valor hardcoded. P1 + // dicta el centrament (consistent amb el comportament pre-refactor). + const auto SPAWN = computeSpawn(cfg_p1); const bool BOTH_ACTIVE = (USE_P1 && USE_P2); // Índex de tile amb què pinta cada jugador — llegit del YAML del mapa @@ -445,8 +424,8 @@ GameScene::GameScene(Config config) if (USE_P1) { p1_x = SPAWN.p1_x + TILE_F; } if (USE_P2) { p2_x = SPAWN.p2_x - TILE_F; } } - players_.emplace_back(std::make_unique(ANIM_P1, ID_P1, p1_x, SPAWN.y, CONTROLS_P1, Map::makeInfo(), P1_PAINT)); - players_.emplace_back(std::make_unique(ANIM_P2, ID_P2, p2_x, SPAWN.y, CONTROLS_P2, Map::makeInfo(), P2_PAINT)); + players_.emplace_back(std::make_unique(std::move(cfg_p1), ANIM_P1, ID_P1, p1_x, SPAWN.y, CONTROLS_P1, Map::makeInfo(), P1_PAINT)); + players_.emplace_back(std::make_unique(std::move(cfg_p2), ANIM_P2, ID_P2, p2_x, SPAWN.y, CONTROLS_P2, Map::makeInfo(), P2_PAINT)); // Tots els slots arrenquen DISABLED. Els actius els reactivarà el // callback de fi de música d'INTRO via `completeIntro()` (spawn aeri @@ -499,10 +478,12 @@ GameScene::GameScene(Config config) auto hud_font_surface = Resource::Cache::get().getSurface(HUD_FONT_GIF_KEY); auto hud_font = std::make_shared(hud_font_surface, UiConfig::get().hudFont().fnt); hud_font->setDefaultKerning(UiConfig::get().hudFont().kerning); - // Vides inicials del HUD = `initial_lives` del YAML del jugador - // (`Player::loadFrom` ja ha omplert `death_config` abans d'aquí). - // Sense això, el marcador es queda fixe a 3 i el YAML perd efecte. - hud_ = std::make_unique(entity_manager_.getBus(), hud_font, Player::getInitialLives()); + // Vides inicials del HUD = `initial_lives` del Config del P1. + // (Cada Player té el seu propi Config post-refactor a per-instance; + // el HUD agafa el de P1 com a referència — historicament era el + // valor compartit per tots els jugadors). Si en algun futur P1 i P2 + // arrenquen amb vides diferents, caldria un HUD per slot. + hud_ = std::make_unique(entity_manager_.getBus(), hud_font, players_[0]->getInitialLives()); // Activem el bloc HUD per als slots que arranquen en partida. Els // inactius queden mostrant el prompt "POLSA START / PER JUGAR" via // `refreshJoinPrompt` cada frame. Tots dos s'haurien de poder @@ -564,19 +545,19 @@ GameScene::~GameScene() { } } -auto GameScene::computeSpawn() -> PlayerSpawn { +auto GameScene::computeSpawn(const Player::Config& cfg) -> PlayerSpawn { // Centre de la columna del passadís en píxels canvas. La caixa de // col·lisió del jugador (12 px) cap dins del passadís (16 px) amb 2 // px de marge a banda i banda; alinear el sprite al centre de la // columna garanteix que la base del jugador i la del passadís // coincideixin verticalment. const float DOOR_CENTER_X = static_cast((Map::door_col * Map::tile_size) + Map::tilemap_origin_x) + (static_cast(Map::tile_size) / 2.0F); - const float CENTER_TL_X = DOOR_CENTER_X - (static_cast(Player::sprite_w) / 2.0F); + const float CENTER_TL_X = DOOR_CENTER_X - (static_cast(cfg.sprite_w) / 2.0F); // Y: centre del corredor exterior superior (mateix valor que abans; // el corredor és simètric respecte a `playfield_y`). const float CORRIDOR_CENTER_Y = static_cast(Map::playfield_y) + (static_cast(Map::outer_corridor) / 2.0F); - const float SPAWN_Y = CORRIDOR_CENTER_Y - static_cast(Player::collision_y_offset) - (static_cast(Player::collision_size) / 2.0F); + const float SPAWN_Y = CORRIDOR_CENTER_Y - static_cast(cfg.collision_y_offset) - (static_cast(cfg.collision_size) / 2.0F); // Mode BOTH: P1 a -tile_size, P2 a +tile_size respecte al centre. // Els callsites individuals decideixen si usar aquests valors o si @@ -594,14 +575,18 @@ void GameScene::loadRoom(const std::string& yaml_path) { // CLOSED amb visited buit, momies i altres actors a la posició inicial. config_.map_yaml = yaml_path; Map::loadFrom(yaml_path); - // Refresquem els statics del Player a partir del YAML i tornem a - // aplicar l'override d'aquesta sala. Sense això, una sala amb - // `players: false` deixa `light_radius=0` i, en transicionar a una - // altra sala que no toqui el camp (o digui `players: true`), els - // jugadors no tornen a emetre — l'override actual és "additiu" - // sobre els statics existents, no un reset. - Player::loadFrom(config_.player_yaml); - applyRoomLightOverrideToPlayer(); + // Refresquem la Config de cada jugador llegint el seu YAML i aplicant + // l'override d'aquesta sala. Sense això, una sala amb `players: false` + // deixaria `light_radius=0` al jugador i, en transicionar a una altra + // sala que no toqui el camp (o digui `players: true`), els jugadors + // no tornarien a emetre — l'override és "additiu" sobre el Config + // actual, no un reset, així que cal partir sempre del YAML. + if (!players_.empty() && players_[0] != nullptr) { + players_[0]->setConfig(loadPlayerConfigWithRoomOverride(config_.player_yaml)); + } + if (players_.size() >= 2 && players_[1] != nullptr) { + players_[1]->setConfig(loadPlayerConfigWithRoomOverride(config_.player2_yaml)); + } // Tilemap pre-blita els tiles a un buffer ARGB intern al ctor — sense // reconstruir-lo, la imatge de la sala anterior es queda fixada per a // sempre. PaintLayer captura `tileset_path_`/`tileset_cols_` per valor @@ -705,7 +690,7 @@ void GameScene::respawnPlayersInstant() { // música de sala, sense caiguda aèria). Reusem `computeSpawn` i el // mateix patró de `triggerPlayerSpawn` però cridem `respawn(x, y)` // (teleport instantani) en lloc de `spawnFromAir(x, y)`. - const auto SPAWN = computeSpawn(); + const auto SPAWN = computeSpawn(players_[0]->getConfig()); const bool BOTH_ACTIVE = (players_active_[0] && players_active_[1]); const float SINGLE_X = SPAWN.p1_x + static_cast(Map::tile_size); for (std::size_t i = 0; i < players_.size(); ++i) { @@ -736,7 +721,7 @@ void GameScene::triggerPlayerSpawn(std::size_t slot_index) { // Recalcula spawn per si door_col canvia entre sales (avui no, però // la lògica ja queda preparada per a l'itinerari). - const auto SPAWN = computeSpawn(); + const auto SPAWN = computeSpawn(players_[0]->getConfig()); const bool BOTH_ACTIVE = (players_active_[0] && players_active_[1]); // Mode single-player: l'únic actiu queda centrat sota la porta. Els // valors de SPAWN.p1_x/p2_x estan fets per al cas BOTH (offset ±tile); @@ -841,7 +826,7 @@ void GameScene::joinPlayer(std::size_t slot_index) { // porta de sortida. Mai el centre (en single-player la cinemàtica // inicial sí que centra el slot actiu, però en un join mid-partida // volem el costat per coherència visual amb la regla 2). - const auto SPAWN = computeSpawn(); + const auto SPAWN = computeSpawn(players_[0]->getConfig()); const float X = (slot_index == 0) ? SPAWN.p1_x : SPAWN.p2_x; players_[slot_index]->setSpawnPoint(X, SPAWN.y); players_[slot_index]->resetForJoin(); @@ -1055,22 +1040,16 @@ void GameScene::applyLightingPass() { // serà tocar `Map::light_level` per sales concretes. std::vector emitters; emitters.reserve(players_.size() + entity_manager_.entityCount()); - const int PLAYER_R = Player::getLightRadius(); - // Quan el YAML del jugador no declara `light:`, `PLAYER_R` és 0 i - // `Lighting::apply` salta el cercle al filtre intern. No fem un - // gating extern aquí (els jugadors són només 1-2 actors i el cost - // d'afegir-los al vector és menyspreable). - const bool PLAYER_FLICKER = Player::getLightFlicker(); - const Lighting::Color PLAYER_COLOR{ - .r = Player::getLightColorR(), - .g = Player::getLightColorG(), - .b = Player::getLightColorB(), - }; - const Lighting::FlickerConfig PLAYER_FP = Player::getFlickerParams(); + // Cada jugador té el seu propi config (post-refactor a per-instance): + // radi, color, flicker_params, etc. poden diferir entre P1 i P2. + // Quan el YAML d'un jugador no declara `light:`, el radi és 0 i + // `Lighting::apply` salta el cercle al filtre intern. for (const auto& p : players_) { if (p->isOutOfPlay()) { continue; } + const int R = p->getLightRadius(); const auto C = p->getLightCenter(); - emitters.push_back({.cx = C.x, .cy = C.y, .radius = PLAYER_R, .seed = p->getLightSeed(), .flicker = PLAYER_FLICKER, .color = PLAYER_COLOR, .flicker_params = PLAYER_FP}); + const Lighting::Color COL{.r = p->getLightColorR(), .g = p->getLightColorG(), .b = p->getLightColorB()}; + emitters.push_back({.cx = C.x, .cy = C.y, .radius = R, .seed = p->getLightSeed(), .flicker = p->getLightFlicker(), .color = COL, .flicker_params = p->getFlickerParams()}); } for (const auto* e : entity_manager_.getEntities()) { const int R = e->getLightRadius(); diff --git a/source/game/scenes/game_scene.hpp b/source/game/scenes/game_scene.hpp index 602ad73..fc6d3ea 100644 --- a/source/game/scenes/game_scene.hpp +++ b/source/game/scenes/game_scene.hpp @@ -11,6 +11,7 @@ #include "core/ui/ui_config.hpp" #include "game/debug_config.hpp" #include "game/entities/entity_manager.hpp" +#include "game/entities/player.hpp" #include "game/map/paint_layer.hpp" #include "game/map/tilemap.hpp" #include "game/scenes/game_over_fsm.hpp" @@ -21,7 +22,6 @@ #endif class Hud; -class Player; class Text; class GameScene : public Scene { @@ -382,7 +382,11 @@ class GameScene : public Scene { float p2_x{0.0F}; float y{0.0F}; }; - [[nodiscard]] static auto computeSpawn() -> PlayerSpawn; + // Calcula la posició de spawn dels jugadors usant la geometria del + // sprite/col·lisió del `Config` passat (típicament la del P1, que + // dicta el centrament — si en algun futur P2 té geometria diferent, + // el seu propi spawn pot recalcular-se amb el seu `cfg`). + [[nodiscard]] static auto computeSpawn(const Player::Config& cfg) -> PlayerSpawn; // Recarrega la sala apuntada per `yaml_path`: refresca Map (geometria, // porta tancada), entitats (sarcs/porta/momies des de zero), neteja