Merge branch 'feat/player-immunity'

This commit is contained in:
JailDesigner committed 2026-05-05 13:19:56 +02:00
commit 165499b9d4
7 files changed
+199 -1

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
JASC-PAL
0100
32
0 0 0
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
+12
View File
@@ -131,3 +131,15 @@ whip:
death: death:
respawn_delay_ms: 500 respawn_delay_ms: 500
lives: 3 lives: 3
# Immunitat post-spawn: durant `duration_ms` ms, els hazards no
# poden ferir ni matar el jugador (els forats sí que el maten — la
# caiguda no és dany convencional). Visualment es manifesta amb un
# parpadeig que alterna la paleta normal del sprite amb la paleta
# `palette` (basename de `data/player/<name>.pal`) cada
# `blink_period_ms` ms. Si `duration_ms` és 0 (o el bloc manca), la
# mecànica queda desactivada i el comportament és l'antic.
immunity:
duration_ms: 3000
blink_period_ms: 100
palette: immune
+12
View File
@@ -131,3 +131,15 @@ whip:
death: death:
respawn_delay_ms: 500 respawn_delay_ms: 500
lives: 3 lives: 3
# Immunitat post-spawn: durant `duration_ms` ms, els hazards no
# poden ferir ni matar el jugador (els forats sí que el maten — la
# caiguda no és dany convencional). Visualment es manifesta amb un
# parpadeig que alterna la paleta normal del sprite amb la paleta
# `palette` (basename de `data/player/<name>.pal`) cada
# `blink_period_ms` ms. Si `duration_ms` és 0 (o el bloc manca), la
# mecànica queda desactivada i el comportament és l'antic.
immunity:
duration_ms: 3000
blink_period_ms: 100
palette: immune
+4
View File
@@ -64,6 +64,10 @@ class Surface {
void setPalette(const Palette& palette) { palette_ = palette; } void setPalette(const Palette& palette) { palette_ = palette; }
[[nodiscard]] auto getPaletteColor(Uint8 index) const -> Uint32 { return palette_[index]; } [[nodiscard]] auto getPaletteColor(Uint8 index) const -> Uint32 { return palette_[index]; }
// Accés de lectura a la paleta sencera. L'usen consumidors que volen
// fer save/restore (p.ex. el blink d'immunitat del Player, que aplica
// una paleta plana abans del render i restaura la normal després).
[[nodiscard]] auto getPalette() const -> const Palette& { return palette_; }
static void initializeSubPalette(SubPalette& palette) { std::iota(palette.begin(), palette.end(), 0); } static void initializeSubPalette(SubPalette& palette) { std::iota(palette.begin(), palette.end(), 0); }
+77
View File
@@ -14,6 +14,8 @@
#include "core/input/input_state.hpp" #include "core/input/input_state.hpp"
#include "core/rendering/canvas.hpp" #include "core/rendering/canvas.hpp"
#include "core/rendering/screen.hpp" #include "core/rendering/screen.hpp"
#include "core/rendering/surface.hpp"
#include "core/resources/palette_loader.hpp"
#include "core/resources/resource_cache.hpp" #include "core/resources/resource_cache.hpp"
#include "core/resources/resource_helper.hpp" #include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp" #include "external/fkyaml_node.hpp"
@@ -75,6 +77,7 @@ Player::JumpConfig Player::jump_config;
Player::HurtConfig Player::hurt_config; Player::HurtConfig Player::hurt_config;
Player::WhipConfig Player::whip_config; Player::WhipConfig Player::whip_config;
Player::DeathConfig Player::death_config; Player::DeathConfig Player::death_config;
Player::ImmunityConfig Player::immunity_config;
void Player::loadFrom(const std::string& yaml_path) { void Player::loadFrom(const std::string& yaml_path) {
auto bytes = Resource::Helper::loadFile(yaml_path); auto bytes = Resource::Helper::loadFile(yaml_path);
@@ -131,6 +134,11 @@ void Player::loadFrom(const std::string& yaml_path) {
// vides. Si manca, el respawn és immediat i les vides queden a 3 // 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). // per defecte (no és un valor crític: avui no hi ha game over).
readDeathBlock(yaml); 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);
} }
void Player::readCollisionBlock(const fkyaml::node& yaml, const std::string& yaml_path) { void Player::readCollisionBlock(const fkyaml::node& yaml, const std::string& yaml_path) {
@@ -205,6 +213,20 @@ void Player::readDeathBlock(const fkyaml::node& yaml) {
if (d.contains("lives")) { death_config.initial_lives = d["lives"].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 (`data/player/<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, "data/player/" + immunity_config.palette_key + ".pal");
}
}
void Player::readWhipBlock(const fkyaml::node& yaml) { void Player::readWhipBlock(const fkyaml::node& yaml) {
whip_config = WhipConfig{}; whip_config = WhipConfig{};
if (!yaml.contains("whip")) { return; } if (!yaml.contains("whip")) { return; }
@@ -372,6 +394,12 @@ Player::Player(const std::string& anim_key, int id, float x, float y, const Cont
// GameScene el reescriu via `setSpawnPoint` quan canvia de sala. // GameScene el reescriu via `setSpawnPoint` quan canvia de sala.
spawn_x_ = x; spawn_x_ = x;
spawn_y_ = y; spawn_y_ = y;
// Cachegem la paleta normal del sprite ara que la Surface ja està
// carregada. El render del blink d'immunitat la restaura després
// d'aplicar la paleta plana — fer-ho via membre evita dependre de
// l'estat momentani de la Surface (que pot ser compartida entre
// jugadors del mateix personatge).
if (surface_ != nullptr) { normal_palette_ = surface_->getPalette(); }
} }
void Player::update(float delta_time) { void Player::update(float delta_time) {
@@ -469,6 +497,16 @@ void Player::tick(float delta_time) {
// que GameScene el reactiva (respawn / spawnFromAir). // que GameScene el reactiva (respawn / spawnFromAir).
if (isOutOfPlay()) { return; } if (isOutOfPlay()) { return; }
// Decrement del timer d'immunitat. Independent de l'estat: si el
// jugador mor durant la immunitat (només via forat, que no es gateja),
// el `respawn` rearma una nova finestra. Si la finestra acaba mentre
// és AIRBORNE/HURT, no passa res — el gate dels hazards passa a
// quedar obert al primer frame post-zero.
if (immunity_remaining_ms_ > 0.0F) {
immunity_remaining_ms_ -= delta_time * 1000.0F;
immunity_remaining_ms_ = std::max(immunity_remaining_ms_, 0.0F);
}
// DYING: animació `death_spinning` + pausa de respawn. Ignorem tot // DYING: animació `death_spinning` + pausa de respawn. Ignorem tot
// l'input (ni moviment, ni salt, ni atac). El moviment queda // l'input (ni moviment, ni salt, ni atac). El moviment queda
// congelat al lloc de la mort; `applyMovement` avança l'animació i // congelat al lloc de la mort; `applyMovement` avança l'animació i
@@ -886,6 +924,11 @@ auto Player::chooseHurtDirection(Cardinal preferred) const -> Cardinal {
} }
void Player::enterHurt(Cardinal preferred) { void Player::enterHurt(Cardinal preferred) {
// Gate d'immunitat: tots els callers de dany (whip friendly fire,
// caiguda damunt, futurs hazards) passen per ací. Una sola línia
// cobreix la finestra de gràcia post-spawn.
if (isImmune()) { return; }
// Si la mecànica no està configurada (speed = 0), no fem res — el // Si la mecànica no està configurada (speed = 0), no fem res — el
// comportament queda equivalent al d'abans, sense empenta. // 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 (hurt_config.initial_speed_px_per_s <= 0.0F || hurt_config.brake_time_ms <= 0) { return; }
@@ -1140,6 +1183,13 @@ void Player::applyMovement(float delta_time) {
} }
void Player::enterDying(DeathCause cause) { void Player::enterDying(DeathCause cause) {
// Gate d'immunitat amb bypass per a HOLE: la caiguda per agujero NO
// és dany convencional sinó "sortida lògica del món", així que els
// forats han de matar fins i tot el jugador immune. Qualsevol altra
// causa (mòmia letal via `player_killed`, etc.) queda neutralitzada
// mentre dura la finestra de gràcia.
if (cause != DeathCause::HOLE && isImmune()) { return; }
// Idempotent. Un segon contacte de momia mentre el jugador ja està // Idempotent. Un segon contacte de momia mentre el jugador ja està
// en DYING no reinicia l'animació ni el comptador. Tampoc disparem // en DYING no reinicia l'animació ni el comptador. Tampoc disparem
// el cicle si el jugador està fora de joc (EXITED al passar la // el cicle si el jugador està fora de joc (EXITED al passar la
@@ -1311,6 +1361,25 @@ void Player::render() {
}); });
} }
// Blink d'immunitat: cada `blink_period_ms` ms, el render alterna
// entre la paleta normal i la paleta plana. La fase es deriva del
// timer restant — al spawn (timer = duration) toca paleta plana
// perquè el feedback sigui immediat. La Surface pot ser compartida
// entre dos jugadors del mateix personatge: com que el render del
// joc és seqüencial, el save/restore al voltant del render del
// sprite és segur — quan acaba aquesta funció, la Surface torna a
// tenir la paleta normal i la pròxima crida pot fer el seu propi
// swap independentment.
const bool BLINK_ON =
immunity_remaining_ms_ > 0.0F &&
immunity_config.blink_period_ms > 0 &&
!immunity_config.palette_key.empty() &&
surface_ != nullptr &&
(static_cast<int>(immunity_remaining_ms_) / immunity_config.blink_period_ms) % 2 == 0;
if (BLINK_ON) {
surface_->setPalette(Resource::Cache::get().getPalette(immunity_config.palette_key));
}
if (jump_z_ == 0.0F) { if (jump_z_ == 0.0F) {
AnimatedSprite::render(); AnimatedSprite::render();
} else { } else {
@@ -1320,6 +1389,8 @@ void Player::render() {
pos_.y = SAVED_Y; pos_.y = SAVED_Y;
} }
if (BLINK_ON) { surface_->setPalette(normal_palette_); }
if (CLIP_HOLE) { Rendering::popClip(); } if (CLIP_HOLE) { Rendering::popClip(); }
} }
@@ -1486,6 +1557,12 @@ void Player::respawn(float x, float y) {
setCurrentAnimation("stand_south"); setCurrentAnimation("stand_south");
setPosX(x); setPosX(x);
setPosY(y); setPosY(y);
// Immunitat post-spawn: armem la finestra de gràcia amb la durada del
// YAML. Cobreix l'aparició inicial (`spawnFromAir` → `respawn`) i el
// respawn post-mort (`respawnFromDying` → `respawn`). Si el bloc
// `immunity:` manca al YAML, `duration_ms` és 0 i `setImmunity` és
// no-op — comportament idèntic al d'abans.
setImmunity(immunity_config.duration_ms);
} }
void Player::disable() { void Player::disable() {
+58
View File
@@ -8,6 +8,7 @@
#include <vector> #include <vector>
#include "core/rendering/sprite/animated_sprite.hpp" #include "core/rendering/sprite/animated_sprite.hpp"
#include "core/rendering/surface.hpp"
#include "external/fkyaml_node.hpp" #include "external/fkyaml_node.hpp"
#include "game/map/map.hpp" #include "game/map/map.hpp"
@@ -311,6 +312,25 @@ class Player : public AnimatedSprite {
// game over. Públic per a HUD i tests. // game over. Públic per a HUD i tests.
[[nodiscard]] auto getLivesRemaining() const -> int { return lives_remaining_; } [[nodiscard]] auto getLivesRemaining() const -> int { return lives_remaining_; }
// Activa la immunitat per `duration_ms` ms. Mentre `isImmune()` és
// cert, `enterHurt` i `enterDying(GENERIC)` són no-op — els hazards no
// poden tocar el jugador. La caiguda per forats (`enterDying(HOLE)`)
// segueix funcionant: els forats no són dany convencional, són una
// sortida lògica del món. Idempotent: una segona crida reescriu el
// timer (no acumula). Visualment, el render alterna la paleta del
// sprite amb una paleta plana cada `immunity_config.blink_period_ms`
// ms. Si `duration_ms <= 0` és no-op (manera de cancel·lar
// explícitament la immunitat seria `setImmunity(0)` — equivalent al
// comportament natural quan el timer s'esgota).
void setImmunity(float duration_ms) {
if (duration_ms > 0.0F) { immunity_remaining_ms_ = duration_ms; }
}
// Cert mentre el jugador té immunitat activa. Comprovat per
// `enterHurt` i `enterDying` per filtrar els hazards. La causa HOLE
// ignora aquest gate (vegeu `enterDying`).
[[nodiscard]] auto isImmune() const -> bool { return immunity_remaining_ms_ > 0.0F; }
// Reset complet a l'estat inicial: posició a (x, y), velocitats a 0, // Reset complet a l'estat inicial: posició a (x, y), velocitats a 0,
// estat GROUNDED, qualsevol flag de control reinicialitzat. Cridat // estat GROUNDED, qualsevol flag de control reinicialitzat. Cridat
// per GameScene a la transició ROOM_OUTRO → ROOM_INTRO. Idempotent // per GameScene a la transició ROOM_OUTRO → ROOM_INTRO. Idempotent
@@ -664,6 +684,30 @@ class Player : public AnimatedSprite {
// l'animació) i `initial_lives` es manté al default (3). // l'animació) i `initial_lives` es manté al default (3).
static void readDeathBlock(const fkyaml::node& yaml); 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/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/player/<palette_key>.pal`). Mateix patró que els altres
// readers — `loadFrom` el crida al final.
static void readImmunityBlock(const fkyaml::node& yaml);
// Activa l'estat ATTACKING en la direcció cardinal indicada (la // Activa l'estat ATTACKING en la direcció cardinal indicada (la
// direcció cap on mira el jugador) i publica `whip_spawn` al bus // direcció cap on mira el jugador) i publica `whip_spawn` al bus
// perquè el GameScene cree el `WhipAttack` corresponent. Posa el // perquè el GameScene cree el `WhipAttack` corresponent. Posa el
@@ -743,6 +787,20 @@ class Player : public AnimatedSprite {
// LANDING_STUN i tot l'input queda bloquejat. // LANDING_STUN i tot l'input queda bloquejat.
float landing_stun_remaining_ms_{0.0F}; float landing_stun_remaining_ms_{0.0F};
// Temps restant d'immunitat (ms). Decreix cada frame al `tick`,
// independentment de l'estat — la immunitat és ortogonal al state
// machine. Mentre > 0, `enterHurt` i `enterDying(GENERIC)` són no-op.
// L'arma `setImmunity()` (cridada per `respawn` amb la durada del
// YAML), i el render alterna paleta cada `immunity_config.blink_period_ms`.
float immunity_remaining_ms_{0.0F};
// Còpia de la paleta normal del sprite (extreta de `surface_->getPalette()`
// al ctor). El render del blink l'usa per restaurar la paleta després
// d'aplicar la versió plana cada mig-cicle. Cachejada perquè la
// Surface pot ser compartida entre dos jugadors del mateix personatge
// i no és segur consultar-la entre swaps.
Palette normal_palette_{};
// Estat anterior del botó de salt per fer detecció de flanc (saltem // Estat anterior del botó de salt per fer detecció de flanc (saltem
// només a la transició no-premut → premut, no mentre es manté). Es // només a la transició no-premut → premut, no mentre es manté). Es
// manté un per teclat i un per mando perquè poden conviure. // manté un per teclat i un per mando perquè poden conviure.
+1 -1
View File
@@ -3,7 +3,7 @@
namespace Texts { namespace Texts {
constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte ARCADE — JailDesigner"; constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte ARCADE — JailDesigner";
constexpr const char* COPYRIGHT = "© 2026 JailDesigner"; constexpr const char* COPYRIGHT = "© 2026 JailDesigner";
constexpr const char* VERSION = "0.32.0"; // Single source of truth — Makefile el grep constexpr const char* VERSION = "0.33.0"; // Single source of truth — Makefile el grep
} // namespace Texts } // namespace Texts
namespace GameCanvas { namespace GameCanvas {