Merge branch 'refactor/player-instance-config'

This commit is contained in:
JailDesigner committed 2026-05-13 21:42:49 +02:00
commit a02706ac8b
4 files changed
+514 -589

No files matched your search

+271 -299
View File
@@ -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<int>();
h = size_node[1].get_value<int>();
}
// 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<int>();
out.sprite_h = size_node[1].get_value<int>();
}
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<int>();
out.base_size = c["base_size"].get_value<int>();
// 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<int>() : 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<int>() : 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<int>(); }
if (i.contains("threshold_ms")) { out.inertia.threshold_ms = i["threshold_ms"].get_value<int>(); }
}
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<int>(); }
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<std::string>()); }
if (tr.contains("east_to_west")) { out.turn.e_to_w = Player::cardinalFromString(tr["east_to_west"].get_value<std::string>()); }
if (tr.contains("north_to_south")) { out.turn.n_to_s = Player::cardinalFromString(tr["north_to_south"].get_value<std::string>()); }
if (tr.contains("south_to_north")) { out.turn.s_to_n = Player::cardinalFromString(tr["south_to_north"].get_value<std::string>()); }
}
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<float>(); }
if (j.contains("gravity")) { out.jump.gravity_px_per_s2 = j["gravity"].get_value<float>(); }
if (j.contains("landing_stun_ms")) { out.jump.landing_stun_ms = j["landing_stun_ms"].get_value<int>(); }
if (j.contains("sound")) {
out.jump.sound_key = j["sound"].get_value<std::string>();
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<std::string>();
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<float>(); }
if (h.contains("brake_time_ms")) { out.hurt.brake_time_ms = h["brake_time_ms"].get_value<int>(); }
if (h.contains("sound")) {
out.hurt.sound_key = h["sound"].get_value<std::string>();
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<float>(); }
if (w.contains("swing_ms")) { out.whip.swing_ms = w["swing_ms"].get_value<int>(); }
if (w.contains("cooldown_ms")) { out.whip.cooldown_ms = w["cooldown_ms"].get_value<int>(); }
if (w.contains("damage")) { out.whip.damage = w["damage"].get_value<int>(); }
if (w.contains("sound")) {
out.whip.sound_key = w["sound"].get_value<std::string>();
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<int>(); }
if (d.contains("lives")) { out.death.initial_lives = d["lives"].get_value<int>(); }
}
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<float>(); }
if (im.contains("blink_period_ms")) { out.immunity.blink_period_ms = im["blink_period_ms"].get_value<int>(); }
if (im.contains("palette")) {
out.immunity.palette_key = im["palette"].get_value<std::string>();
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<int>(); }
if (l.contains("flicker")) { out.light_flicker = l["flicker"].get_value<bool>(); }
if (l.contains("color")) {
const auto& c = l["color"];
if (c.is_sequence() && c.size() == 3) {
out.light_color_r = static_cast<std::uint8_t>(std::clamp(c[0].get_value<int>(), 0, 255));
out.light_color_g = static_cast<std::uint8_t>(std::clamp(c[1].get_value<int>(), 0, 255));
out.light_color_b = static_cast<std::uint8_t>(std::clamp(c[2].get_value<int>(), 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<int>();
out.light_offset_y = o[1].get_value<int>();
}
}
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<float>(); }
if (fp.contains("wave_frequency_hz")) { out.flicker_params.wave_frequency_hz = fp["wave_frequency_hz"].get_value<float>(); }
if (fp.contains("noise_amplitude_ratio")) { out.flicker_params.noise_amplitude_ratio = fp["noise_amplitude_ratio"].get_value<float>(); }
if (fp.contains("noise_frequency_hz")) { out.flicker_params.noise_frequency_hz = fp["noise_frequency_hz"].get_value<float>(); }
}
}
} // 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<int>(); }
if (i.contains("threshold_ms")) { inertia_config.threshold_ms = i["threshold_ms"].get_value<int>(); }
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;
}
// 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<int>(); }
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<std::string>()); }
if (tr.contains("east_to_west")) { turn_config.e_to_w = cardinalFromString(tr["east_to_west"].get_value<std::string>()); }
if (tr.contains("north_to_south")) { turn_config.n_to_s = cardinalFromString(tr["north_to_south"].get_value<std::string>()); }
if (tr.contains("south_to_north")) { turn_config.s_to_n = cardinalFromString(tr["south_to_north"].get_value<std::string>()); }
}
}
// Bloc opcional `jump:` — si manca, `impulse_px_per_s` queda a 0 i el
// botó de salt no fa res. Extret a un mètode estàtic per acotar la
// complexitat cognitiva de `loadFrom` sota el llindar de clang-tidy.
readJumpBlock(yaml);
// Bloc opcional `hurt:` — estat post-impacte. Si manca o el speed
// és 0, l'estat HURT mai s'activa (cas degenerat).
readHurtBlock(yaml);
// 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);
}
void Player::readLightBlock(const fkyaml::node& yaml) {
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;
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<int>(); }
if (l.contains("flicker")) { light_flicker = l["flicker"].get_value<bool>(); }
// `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::uint8_t>(std::clamp(c[0].get_value<int>(), 0, 255));
light_color_g = static_cast<std::uint8_t>(std::clamp(c[1].get_value<int>(), 0, 255));
light_color_b = static_cast<std::uint8_t>(std::clamp(c[2].get_value<int>(), 0, 255));
return;
}
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];
}
// `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<int>();
light_offset_y = o[1].get_value<int>();
}
}
// `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<float>(); }
if (fp.contains("wave_frequency_hz")) { flicker_params.wave_frequency_hz = fp["wave_frequency_hz"].get_value<float>(); }
if (fp.contains("noise_amplitude_ratio")) { flicker_params.noise_amplitude_ratio = fp["noise_amplitude_ratio"].get_value<float>(); }
if (fp.contains("noise_frequency_hz")) { flicker_params.noise_frequency_hz = fp["noise_frequency_hz"].get_value<float>(); }
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<int>();
base_size = c["base_size"].get_value<int>();
// Col·lisió centrada en el quadrat inferior del sprite. Si la caixa és
// més gran que la base (cas habitual, 16 vs 12) el padding és simètric
// a cada costat; si són iguals, padding=0 i la caixa coincideix amb la
// base sense offsets.
collision_padding = (collision_size - base_size) / 2;
collision_x_offset = -collision_padding;
collision_y_offset = (sprite_h - base_size) - collision_padding;
// Hit-box opcional (actor-actor). Si no hi és, s'iguala a la base
// per preservar compatibilitat amb YAMLs antics. Concèntrica amb la
// caixa de col·lisió: offset interior = (collision_size - hit_size) / 2.
hit_size = c.contains("hit_size") ? c["hit_size"].get_value<int>() : collision_size;
const int HIT_INSET = (collision_size - hit_size) / 2;
hit_x_offset = collision_x_offset + HIT_INSET;
hit_y_offset = collision_y_offset + HIT_INSET;
// Alçada del cos, llegida del mateix bloc per coherència amb la
// resta d'entitats (mummy, sarcophagus, etc.). 0 = sense filtre per
// altura — el salt no esquiva l'altre jugador.
body_height = c.contains("height") ? c["height"].get_value<int>() : 0;
}
void Player::readJumpBlock(const fkyaml::node& yaml) {
jump_config = JumpConfig{};
if (!yaml.contains("jump")) { return; }
const auto& j = yaml["jump"];
if (j.contains("impulse")) { jump_config.impulse_px_per_s = j["impulse"].get_value<float>(); }
if (j.contains("gravity")) { jump_config.gravity_px_per_s2 = j["gravity"].get_value<float>(); }
if (j.contains("landing_stun_ms")) { jump_config.landing_stun_ms = j["landing_stun_ms"].get_value<int>(); }
// 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<std::string>();
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<std::string>();
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<float>(); }
if (h.contains("brake_time_ms")) { hurt_config.brake_time_ms = h["brake_time_ms"].get_value<int>(); }
// 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<std::string>();
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<int>(); }
if (d.contains("lives")) { death_config.initial_lives = d["lives"].get_value<int>(); }
}
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<float>(); }
if (im.contains("blink_period_ms")) { immunity_config.blink_period_ms = im["blink_period_ms"].get_value<int>(); }
// 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<std::string>();
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<float>(); }
if (w.contains("swing_ms")) { whip_config.swing_ms = w["swing_ms"].get_value<int>(); }
if (w.contains("cooldown_ms")) { whip_config.cooldown_ms = w["cooldown_ms"].get_value<int>(); }
if (w.contains("damage")) { whip_config.damage = w["damage"].get_value<int>(); }
// 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<std::string>();
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<float>(turn_config.duration_ms);
turning_remaining_ms_ = static_cast<float>(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<float>(body_height)) {
if (MY_AIR && cfg_.body_height > 0 && std::fabs(MY_Z - p->jump_z_) >= static_cast<float>(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<float>(inertia_config.threshold_ms));
const bool INERTIA_ACTIVE = (cfg_.inertia.brake_time_ms > 0) && (motion_time_ms >= static_cast<float>(cfg_.inertia.threshold_ms));
if (!INERTIA_ACTIVE) {
phys = 0.0F;
return;
}
const float DECEL = SPEED_PX_PER_S / (static_cast<float>(inertia_config.brake_time_ms) / 1000.0F);
const float DECEL = SPEED_PX_PER_S / (static_cast<float>(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<float>(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<float>(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<float>(whip_config.swing_ms + whip_config.cooldown_ms);
attacking_remaining_ms_ = static_cast<float>(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<float>(Map::tile_size);
const float RANGE_PX = cfg_.whip.range_tiles * static_cast<float>(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<float>(sprite_w) * 0.5F), .y = getGroundFeetY()},
.payload = WorldPos{.x = getPosX() + (static_cast<float>(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<int>(
0.5F * hurt_config.initial_speed_px_per_s * (static_cast<float>(hurt_config.brake_time_ms) / 1000.0F));
0.5F * cfg_.hurt.initial_speed_px_per_s * (static_cast<float>(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<float>(jump_config.landing_stun_ms);
if (!jump_config.land_sound_key.empty() && Audio::get() != nullptr) {
landing_stun_remaining_ms_ = static_cast<float>(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<float>(sprite_w) * 0.5F), .y = getGroundFeetY()},
.payload = WorldPos{.x = getPosX() + (static_cast<float>(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<float>(death_config.respawn_delay_ms);
respawn_remaining_ms_ = static_cast<float>(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<int>(immunity_remaining_ms_) / immunity_config.blink_period_ms) % 2 == 0;
(static_cast<int>(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<float>(sprite_w) * 0.5F) + static_cast<float>(light_offset_x),
.y = getPosY() - jump_z_ + (static_cast<float>(sprite_h) * 0.5F) + static_cast<float>(light_offset_y),
.x = getPosX() + (static_cast<float>(cfg_.sprite_w) * 0.5F) + static_cast<float>(cfg_.light_offset_x),
.y = getPosY() - jump_z_ + (static_cast<float>(cfg_.sprite_h) * 0.5F) + static_cast<float>(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<int>(getPosX() + (static_cast<float>(sprite_w) * 0.5F));
const auto CENTER_X = static_cast<int>(getPosX() + (static_cast<float>(cfg_.sprite_w) * 0.5F));
const auto FEET_Y = static_cast<int>(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<float>(whip_config.cooldown_ms));
const bool IN_ATTACK_SWING = (attacking_remaining_ms_ > static_cast<float>(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<int, int> {
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<float>(map_.playfield_x - collision_x_offset);
const auto MIN_X = static_cast<float>(map_.playfield_x - cfg_.collision_x_offset);
const auto MAX_X = static_cast<float>(
map_.playfield_x + map_.playfield_w - collision_x_offset - collision_size);
auto min_y = static_cast<float>(map_.playfield_y - collision_y_offset);
map_.playfield_x + map_.playfield_w - cfg_.collision_x_offset - cfg_.collision_size);
auto min_y = static_cast<float>(map_.playfield_y - cfg_.collision_y_offset);
const auto MAX_Y = static_cast<float>(
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<float>(collision_x_offset);
const float BASE_RIGHT = BASE_X + static_cast<float>(collision_size);
const float BASE_X = getPosX() + static_cast<float>(cfg_.collision_x_offset);
const float BASE_RIGHT = BASE_X + static_cast<float>(cfg_.collision_size);
const auto CORRIDOR_LEFT = static_cast<float>(map_.door_corridor_x);
const auto CORRIDOR_RIGHT = static_cast<float>(map_.door_corridor_x + map_.door_corridor_w);
if (BASE_X >= CORRIDOR_LEFT && BASE_RIGHT <= CORRIDOR_RIGHT) {
min_y = static_cast<float>(map_.door_corridor_y - collision_y_offset);
min_y = static_cast<float>(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<float>(hit_size) * 0.5F;
setPosX(center_x - static_cast<float>(hit_x_offset) - HIT_HALF);
setPosY(center_y - static_cast<float>(hit_y_offset) - HIT_HALF);
const float HIT_HALF = static_cast<float>(cfg_.hit_size) * 0.5F;
setPosX(center_x - static_cast<float>(cfg_.hit_x_offset) - HIT_HALF);
setPosY(center_y - static_cast<float>(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<float>(sprite_h) + OFFSCREEN_MARGIN_PX;
const float SPAWN_HEIGHT = y + static_cast<float>(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<float>(collision_x_offset),
.y = getPosY() + static_cast<float>(collision_y_offset),
.w = static_cast<float>(collision_size),
.h = static_cast<float>(collision_size),
.x = getPosX() + static_cast<float>(cfg_.collision_x_offset),
.y = getPosY() + static_cast<float>(cfg_.collision_y_offset),
.w = static_cast<float>(cfg_.collision_size),
.h = static_cast<float>(cfg_.collision_size),
};
}
auto Player::getHitBox() const -> SDL_FRect {
return {
.x = getPosX() + static_cast<float>(hit_x_offset),
.y = getPosY() + static_cast<float>(hit_y_offset),
.w = static_cast<float>(hit_size),
.h = static_cast<float>(hit_size),
.x = getPosX() + static_cast<float>(cfg_.hit_x_offset),
.y = getPosY() + static_cast<float>(cfg_.hit_y_offset),
.w = static_cast<float>(cfg_.hit_size),
.h = static_cast<float>(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<float>(INSET),
.y = base.y + static_cast<float>(INSET),
.w = static_cast<float>(hit_size),
.h = static_cast<float>(hit_size),
.w = static_cast<float>(cfg_.hit_size),
.h = static_cast<float>(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<float>(collision_size) / 2.0F;
const auto THRESHOLD = static_cast<float>(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<float>(map_.door_corridor_x) + (static_cast<float>(map_.door_corridor_w - collision_size) / 2.0F);
const auto THRESHOLD = static_cast<float>(collision_size) / 2.0F;
const float CORRIDOR_TARGET = static_cast<float>(map_.door_corridor_x) + (static_cast<float>(map_.door_corridor_w - cfg_.collision_size) / 2.0F);
const auto THRESHOLD = static_cast<float>(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<float>(map_.playfield_y - collision_y_offset);
const auto BASE_Y = static_cast<float>(map_.playfield_y - cfg_.collision_y_offset);
const auto PITCH_Y = static_cast<float>(map_.pitch_y);
const int IDX = std::clamp(
static_cast<int>(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<float>(map_.playfield_x - collision_x_offset);
const auto BASE_X = static_cast<float>(map_.playfield_x - cfg_.collision_x_offset);
const auto PITCH_X = static_cast<float>(map_.pitch_x);
const int IDX = std::clamp(
static_cast<int>(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<float>(map_.door_corridor_x) + (static_cast<float>(map_.door_corridor_w - collision_size) / 2.0F);
const float LANE_DOOR = CORRIDOR_CENTER - static_cast<float>(collision_x_offset);
const float CORRIDOR_CENTER = static_cast<float>(map_.door_corridor_x) + (static_cast<float>(map_.door_corridor_w - cfg_.collision_size) / 2.0F);
const float LANE_DOOR = CORRIDOR_CENTER - static_cast<float>(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; }
+181 -211
View File
@@ -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<float>(collision_y_offset + collision_size);
return getPosY() + static_cast<float>(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/<palette_key>.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};
+56 -77
View File
@@ -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<float>(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<float>(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<Player>(ANIM_P1, ID_P1, p1_x, SPAWN.y, CONTROLS_P1, Map::makeInfo(), P1_PAINT));
players_.emplace_back(std::make_unique<Player>(ANIM_P2, ID_P2, p2_x, SPAWN.y, CONTROLS_P2, Map::makeInfo(), P2_PAINT));
players_.emplace_back(std::make_unique<Player>(std::move(cfg_p1), ANIM_P1, ID_P1, p1_x, SPAWN.y, CONTROLS_P1, Map::makeInfo(), P1_PAINT));
players_.emplace_back(std::make_unique<Player>(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<Text>(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<Hud>(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<Hud>(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<float>((Map::door_col * Map::tile_size) + Map::tilemap_origin_x) + (static_cast<float>(Map::tile_size) / 2.0F);
const float CENTER_TL_X = DOOR_CENTER_X - (static_cast<float>(Player::sprite_w) / 2.0F);
const float CENTER_TL_X = DOOR_CENTER_X - (static_cast<float>(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<float>(Map::playfield_y) + (static_cast<float>(Map::outer_corridor) / 2.0F);
const float SPAWN_Y = CORRIDOR_CENTER_Y - static_cast<float>(Player::collision_y_offset) - (static_cast<float>(Player::collision_size) / 2.0F);
const float SPAWN_Y = CORRIDOR_CENTER_Y - static_cast<float>(cfg.collision_y_offset) - (static_cast<float>(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<float>(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<Lighting::Emitter> 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();
+6 -2
View File
@@ -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