feat(hazard): nou hazard Hole — caiguda per forat amb clip rect

This commit is contained in:
JailDesigner committed 2026-04-30 13:32:17 +02:00
1 parent d4593d0686
commit 4da50571eb
17 files changed
+376 -15

No files matched your search

+3
View File
@@ -50,6 +50,7 @@ set(ENTITY_SOURCES_BASE
source/game/entities/concrete/rolling_rock.cpp source/game/entities/concrete/rolling_rock.cpp
source/game/entities/concrete/wall_trap.cpp source/game/entities/concrete/wall_trap.cpp
source/game/entities/concrete/floor_trap.cpp source/game/entities/concrete/floor_trap.cpp
source/game/entities/concrete/hole.cpp
source/game/entities/concrete/dust.cpp source/game/entities/concrete/dust.cpp
) )
@@ -349,6 +350,7 @@ option(BUILD_TESTS "Build smoke tests" ON)
if(BUILD_TESTS) if(BUILD_TESTS)
add_executable(test_entities_smoke add_executable(test_entities_smoke
tests/test_entities_smoke.cpp tests/test_entities_smoke.cpp
tests/hole_player_stubs.cpp
source/core/resources/resource_helper.cpp source/core/resources/resource_helper.cpp
source/core/resources/resource_pack.cpp source/core/resources/resource_pack.cpp
source/game/map/map.cpp source/game/map/map.cpp
@@ -372,6 +374,7 @@ if(BUILD_TESTS)
add_executable(test_whip_attack add_executable(test_whip_attack
tests/test_whip_attack.cpp tests/test_whip_attack.cpp
tests/whip_attack_player_stubs.cpp tests/whip_attack_player_stubs.cpp
tests/hole_player_stubs.cpp
source/core/resources/resource_helper.cpp source/core/resources/resource_helper.cpp
source/core/resources/resource_pack.cpp source/core/resources/resource_pack.cpp
source/game/map/map.cpp source/game/map/map.cpp
Binary file not shown.

After

Width:  |  Height:  |  Size: 182 B

+26
View File
@@ -0,0 +1,26 @@
name: hole
type: static_hazard
sprite:
path: hole.gif
size: [16, 16]
tile_size: [1, 1]
collision:
# Forat: ocupa el tile sencer (16×16). `height: 0` deixa que un salt
# AIRBORNE el creui sense disparar el contacte (el filtre d'altura de
# `checkPlayerCollisions` el descarta quan `jump_z_ > height`). El
# bucle de contacte admet l'entitat per `flags.triggers_on_overlap`,
# encara que `flags.collides` sigui false.
size: [16, 16]
height: 0
flags:
collides: false
interactable: false
# Avui el loader no llig el bloc `flags:` (és declaratiu); el flag
# real es fixa al constructor C++ de `Hole`. Mantenim el camp per
# coherència amb la resta de plantilles i per quan es cablege la
# lectura genèrica de flags.
triggers_on_overlap: true
animations:
idle: { frames: [0], fps: 1, loop: true }
stats:
damage: 0
+6
View File
@@ -138,3 +138,9 @@ entities:
position: [10, 7] position: [10, 7]
- type: mummy - type: mummy
position: [7, 9] position: [7, 9]
# Forat de prova: tile de sòl pla a la galeria superior, 2 tiles a
# l'est del punt d'aterratge del jugador (door_col=10). Lluny dels
# sarcs i de les momies; accessible caminant cap a l'est tot just
# després del spawn.
- type: hole
position: [12, 3]
+10
View File
@@ -73,6 +73,16 @@ animations:
frame_ms: [60, 80, 100, 120, 140, 160, 180, 200, 220, 240, 260, 280] frame_ms: [60, 80, 100, 120, 140, 160, 180, 200, 220, 240, 260, 280]
loop: false loop: false
# Mort per caiguda a un agujero (DeathCause::HOLE). Frame i durada
# **placeholders** — pendents de Sergio: idealment l'índex de la
# casella buida del spritesheet i una durada curta. `loop: false`
# com `death_spinning` perquè `animationIsCompleted()` dispari el
# respawn timer determinísticament.
death_hole:
frames: [15]
frame_ms: 60
loop: false
# Inèrcia de frenada en soltar la direcció. `threshold_ms` filtra # Inèrcia de frenada en soltar la direcció. `threshold_ms` filtra
# tocs curts (passets d'un tile sense rellisquera). # tocs curts (passets d'un tile sense rellisquera).
inertia: inertia:
+10
View File
@@ -73,6 +73,16 @@ animations:
frame_ms: [60, 80, 100, 120, 140, 160, 180, 200, 220, 240, 260, 280] frame_ms: [60, 80, 100, 120, 140, 160, 180, 200, 220, 240, 260, 280]
loop: false loop: false
# Mort per caiguda a un agujero (DeathCause::HOLE). Frame i durada
# **placeholders** — pendents de Sergio: idealment l'índex de la
# casella buida del spritesheet i una durada curta. `loop: false`
# com `death_spinning` perquè `animationIsCompleted()` dispari el
# respawn timer determinísticament.
death_hole:
frames: [15]
frame_ms: 60
loop: false
# Inèrcia de frenada en soltar la direcció. `threshold_ms` filtra # Inèrcia de frenada en soltar la direcció. `threshold_ms` filtra
# tocs curts (passets d'un tile sense rellisquera). # tocs curts (passets d'un tile sense rellisquera).
inertia: inertia:
@@ -10,6 +10,10 @@ FloorTrap::FloorTrap(const fkyaml::node& yaml) {
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("floor_trap"); name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("floor_trap");
collides_ = false; collides_ = false;
interactable_ = false; interactable_ = false;
// Trigger d'àrea: la trampa no bloqueja el pas però vol rebre
// `onPlayerContact` quan el jugador la solapa. Tanca el gap latent
// que la deixava muda (`checkPlayerCollisions` filtrava pel `collides`).
triggers_on_overlap_ = true;
if (yaml.contains("cycle")) { if (yaml.contains("cycle")) {
const auto& c = yaml["cycle"]; const auto& c = yaml["cycle"];
+51
View File
@@ -0,0 +1,51 @@
#include "game/entities/concrete/hole.hpp"
#include <SDL3/SDL.h>
#include <string>
#include "game/entities/entity_factory.hpp"
#include "game/entities/player.hpp"
Hole::Hole(const fkyaml::node& yaml) {
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("hole");
// Sense xoc físic ni interacció — només trigger d'àrea. El bucle de
// contacte ens admet via `triggers_on_overlap_=true`. Damage queda a
// 0 (no publiquem `player_hit`); la mort la dispara `integrateJump`
// del jugador quan supera `HOLE_FALL_DEPTH`.
collides_ = false;
interactable_ = false;
triggers_on_overlap_ = true;
// Decal de sòl: `Entity::getFeetY()` torna sentinel mínim si aquest
// flag està actiu — així el forat es pinta abans que qualsevol actor.
is_floor_decal_ = true;
setDamage(0);
}
void Hole::onPlayerContact(Player* player) {
if (player == nullptr) { return; }
// Només mossega quan el jugador trepitja el forat de peu (GROUNDED).
// En l'aire, el filtre d'altura del bucle ja l'ha rebutjat (height_=0
// i jump_z_>0 prou per superar-ho); però mantenim el gat per defensa
// davant de futurs canvis al filtre.
if (player->getState() != Player::State::GROUNDED) { return; }
// Re-verificació estricta amb la hit-box interna del jugador (8×8):
// així el contacte només es dispara quan el centre del jugador ha
// entrat al tile del forat, no quan només el frega per la vora.
const SDL_FRect HIT = player->getHitBox();
const SDL_FRect BOX = getCollisionRect();
const bool OVERLAP = HIT.x + HIT.w > BOX.x && BOX.x + BOX.w > HIT.x && HIT.y + HIT.h > BOX.y && BOX.y + BOX.h > HIT.y;
if (!OVERLAP) { return; }
// Snap al centre XY del forat + Y de la base del tile (per al clip
// rect del sprite mentre cau). `enterHoleFall` rep coordenades
// absolutes en el canvas — el player s'encarrega de convertir a la
// seua convenció (cantó superior-esquerra del sprite).
const float CENTER_X = BOX.x + (BOX.w * 0.5F);
const float CENTER_Y = BOX.y + (BOX.h * 0.5F);
const float FLOOR_Y = BOX.y + BOX.h;
player->enterHoleFall(CENTER_X, CENTER_Y, FLOOR_Y);
}
REGISTER_ENTITY("hole", Hole)
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include "external/fkyaml_node.hpp"
#include "game/entities/hazard.hpp"
// Forat al sòl: hazard estàtic de 16×16 que NO bloqueja el pas però
// detecta quan el jugador hi cau a sobre. Característiques:
// - `collides_=false` + `triggers_on_overlap_=true`: rep `onPlayerContact`
// pel bucle de `EntityManager::checkPlayerCollisions` sense bloquejar
// el moviment del jugador.
// - `height_=0`: el filtre de col·lisió per altura existent deixa que
// un jugador AIRBORNE amb `jump_z_>0` el creui sense disparar res
// (saltar per damunt d'un forat).
// - Damage 0: el contacte no publica `player_hit`. En lloc d'això,
// `onPlayerContact` invoca `Player::enterHoleFall`, que transiciona
// el jugador a AIRBORNE amb subestat `falling_through_hole_`.
// - Verificació amb hit-box interna: `onPlayerContact` re-verifica
// l'overlap amb `Player::getHitBox()` (8×8 centrada al sprite),
// així només es dispara quan el centre del jugador entra al tile.
// - Z-order: `is_floor_decal_=true` (heretat) — `Entity::getFeetY()`
// retorna sentinel mínim, així el forat es pinta abans que qualsevol
// actor i les momies passen per davant. L'efecte "el sòl es traga el
// jugador" no es fa amb Y-sort: el `Player::render` aplica un clip
// rect mentre `falling_through_hole_=true` que retalla el sprite per
// sota de la base del tile del forat.
class Hole : public StaticHazard {
public:
explicit Hole(const fkyaml::node& yaml);
// No publica `player_hit`. En el seu lloc, dispara
// `Player::enterHoleFall(center_x, center_y, floor_y)` perquè el
// jugador armi el clip rect a la Y de la base del forat.
void onPlayerContact(Player* player) override;
};
@@ -10,6 +10,9 @@ WallTrap::WallTrap(const fkyaml::node& yaml) {
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("wall_trap"); name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("wall_trap");
collides_ = false; collides_ = false;
interactable_ = false; interactable_ = false;
// Trigger d'àrea: igual que FloorTrap. Tanca el gap latent del filtre
// de `checkPlayerCollisions` que ho deixava mut.
triggers_on_overlap_ = true;
if (yaml.contains("cycle")) { if (yaml.contains("cycle")) {
const auto& c = yaml["cycle"]; const auto& c = yaml["cycle"];
+7
View File
@@ -1,5 +1,6 @@
#include "game/entities/entity.hpp" #include "game/entities/entity.hpp"
#include <limits>
#include <utility> #include <utility>
Entity::Entity(std::string name, TilePos tile, SDL_FRect pixel_rect) Entity::Entity(std::string name, TilePos tile, SDL_FRect pixel_rect)
@@ -38,6 +39,12 @@ auto Entity::getCollisionRect() const -> SDL_FRect {
} }
auto Entity::getFeetY() const -> float { auto Entity::getFeetY() const -> float {
// Decal de sòl: sentinel mínim per quedar al principi del Y-sort
// ascendent de `GameScene` (stable_sort) i així pintar-se ABANS de
// qualsevol actor. Sense passades de render extra.
if (is_floor_decal_) {
return std::numeric_limits<float>::lowest();
}
const auto RECT = getCollisionRect(); const auto RECT = getCollisionRect();
return RECT.y + RECT.h; return RECT.y + RECT.h;
} }
+23
View File
@@ -88,6 +88,27 @@ class Entity {
[[nodiscard]] auto collides() const -> bool { return collides_; } [[nodiscard]] auto collides() const -> bool { return collides_; }
[[nodiscard]] auto interactable() const -> bool { return interactable_; } [[nodiscard]] auto interactable() const -> bool { return interactable_; }
// Trigger d'àrea: l'entitat NO bloqueja el moviment (`collides_=false`)
// però sí vol rebre `onPlayerContact` quan el jugador la solapa. Pensat
// per a hazards "no físics" (forats al sòl, picos retràctils, plaques
// de pressió). El bucle de contacte (`checkPlayerCollisions`) admet
// l'entitat si compleix `collides_ || triggers_on_overlap_`. El filtre
// d'altura per salt (`height_`) i la caixa de col·lisió funcionen igual.
[[nodiscard]] auto triggersOnOverlap() const -> bool { return triggers_on_overlap_; }
void setTriggersOnOverlap(bool v) { triggers_on_overlap_ = v; }
// Decal de sòl: hazards estàtics sense presència vertical (forats,
// plaques de pressió, charcos). Es pinten ABANS de qualsevol actor
// dins del Y-sort de `GameScene` perquè conceptualment són "pintura
// sobre el tile". Implementació: `getFeetY()` retorna un sentinel
// mínim (`std::numeric_limits<float>::lowest()`) per quedar al
// principi del `stable_sort` ascendent — així no calen passades de
// render extra. Les subclasses (p.ex. `Hole`) poden sobreescriure
// `getFeetY` per saltar-se el sentinel en moments concrets (p.ex.
// un forat que està tragant un jugador i ha de pintar-se al damunt).
[[nodiscard]] auto isFloorDecal() const -> bool { return is_floor_decal_; }
void setFloorDecal(bool v) { is_floor_decal_ = v; }
// Marca per a entitats transitòries: efectes (polvet, espurnes, // Marca per a entitats transitòries: efectes (polvet, espurnes,
// explosions...) que viuen una sola animació i s'autoeliminen quan // explosions...) que viuen una sola animació i s'autoeliminen quan
// acaba. L'`EntityManager` purga totes les entitats amb `dead_=true` // acaba. L'`EntityManager` purga totes les entitats amb `dead_=true`
@@ -172,6 +193,8 @@ class Entity {
std::shared_ptr<Surface> surface_{nullptr}; std::shared_ptr<Surface> surface_{nullptr};
bool collides_{false}; bool collides_{false};
bool interactable_{false}; bool interactable_{false};
bool triggers_on_overlap_{false};
bool is_floor_decal_{false};
int height_{0}; int height_{0};
bool dead_{false}; bool dead_{false};
SDL_FlipMode flip_{SDL_FLIP_NONE}; SDL_FlipMode flip_{SDL_FLIP_NONE};
@@ -61,7 +61,12 @@ void EntityManager::renderAll() {
void EntityManager::checkPlayerCollisions(const std::vector<Player*>& players) { void EntityManager::checkPlayerCollisions(const std::vector<Player*>& players) {
if (players.empty()) { return; } if (players.empty()) { return; }
for (const auto& entity : entities_) { for (const auto& entity : entities_) {
if (!entity->collides()) { continue; } // Acceptem entitats sòlides (xoc físic) i triggers d'àrea (no
// bloquegen però volen rebre `onPlayerContact`). Tanquem el gap
// latent de FloorTrap/WallTrap (que tenien `collides_=false` i
// mai no es disparaven) i obrim la porta a hazards no-físics
// com `Hole`.
if (!entity->collides() && !entity->triggersOnOverlap()) { continue; }
const auto BOX = entity->getCollisionRect(); const auto BOX = entity->getCollisionRect();
const int E_HEIGHT = entity->getHeight(); const int E_HEIGHT = entity->getHeight();
for (auto* p : players) { for (auto* p : players) {
@@ -69,6 +74,10 @@ void EntityManager::checkPlayerCollisions(const std::vector<Player*>& players) {
// contacte amb hazards (un segon toc de mòmia mentre la roda // contacte amb hazards (un segon toc de mòmia mentre la roda
// ja és no-op; cap altra entitat l'ha de fer transicionar). // ja és no-op; cap altra entitat l'ha de fer transicionar).
if (p == nullptr || p->isOutOfPlay() || p->isDying()) { continue; } if (p == nullptr || p->isOutOfPlay() || p->isDying()) { continue; }
// Mentre el jugador cau per un forat, ignorem qualsevol altre
// hazard: la transició a `enterDying()` ja la dispara
// `integrateJump` quan supera `HOLE_FALL_DEPTH`.
if (p->isFallingThroughHole()) { continue; }
// Filtre per altura: si el jugador està en l'aire i està per // Filtre per altura: si el jugador està en l'aire i està per
// damunt de l'alçada del cos de l'entitat, no hi ha contacte. // damunt de l'alçada del cos de l'entitat, no hi ha contacte.
// És el mecanisme del salt per esquivar momies, roques, etc. // És el mecanisme del salt per esquivar momies, roques, etc.
+117 -11
View File
@@ -12,6 +12,7 @@
#include "core/input/gamepad_bindings.hpp" #include "core/input/gamepad_bindings.hpp"
#include "core/input/gamepad_manager.hpp" #include "core/input/gamepad_manager.hpp"
#include "core/input/input_state.hpp" #include "core/input/input_state.hpp"
#include "core/rendering/canvas.hpp"
#include "core/rendering/screen.hpp" #include "core/rendering/screen.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"
@@ -910,6 +911,17 @@ auto Player::integrateJump(float delta_time) -> bool {
if (state_ != State::AIRBORNE) { return false; } if (state_ != State::AIRBORNE) { return false; }
jump_vz_ -= jump_config.gravity_px_per_s2 * delta_time; jump_vz_ -= jump_config.gravity_px_per_s2 * delta_time;
jump_z_ += jump_vz_ * 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
// el sprite "s'enfonsa" sota el sòl) i, en superar `HOLE_FALL_DEPTH`,
// disparem la mort. La cinemàtica de respawn (`enterDying →
// spawnFromAir`) reseteja el flag i la posició al spawn point.
if (falling_through_hole_) {
if (jump_z_ <= -HOLE_FALL_DEPTH) {
enterDying(DeathCause::HOLE);
}
return false;
}
if (jump_z_ <= 0.0F) { if (jump_z_ <= 0.0F) {
jump_z_ = 0.0F; jump_z_ = 0.0F;
jump_vz_ = 0.0F; jump_vz_ = 0.0F;
@@ -1075,7 +1087,7 @@ void Player::applyMovement(float delta_time) {
if (state_ == State::GROUNDED) { publishTileEnteredIfChanged(); } if (state_ == State::GROUNDED) { publishTileEnteredIfChanged(); }
} }
void Player::enterDying() { void Player::enterDying(DeathCause cause) {
// 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
@@ -1091,6 +1103,8 @@ void Player::enterDying() {
being_pushed_ = false; being_pushed_ = false;
jump_z_ = 0.0F; jump_z_ = 0.0F;
jump_vz_ = 0.0F; jump_vz_ = 0.0F;
falling_through_hole_ = false;
hole_clip_bottom_y_ = 0.0F;
landing_stun_remaining_ms_ = 0.0F; landing_stun_remaining_ms_ = 0.0F;
attacking_remaining_ms_ = 0.0F; attacking_remaining_ms_ = 0.0F;
turning_remaining_ms_ = 0.0F; turning_remaining_ms_ = 0.0F;
@@ -1102,7 +1116,18 @@ void Player::enterDying() {
death_animation_complete_ = false; death_animation_complete_ = false;
respawn_remaining_ms_ = 0.0F; respawn_remaining_ms_ = 0.0F;
setCurrentAnimation("death_spinning"); // Presentació segons la causa. Cada `DeathCause` té la seua animació
// dedicada al YAML del player; `applyDyingMovement` arma el respawn
// timer quan `animationIsCompleted()` flanqueja, igual per a totes.
switch (cause) {
case DeathCause::HOLE:
setCurrentAnimation("death_hole");
break;
case DeathCause::GENERIC:
default:
setCurrentAnimation("death_spinning");
break;
}
// So del cop letal: reaprofitem `hurt.wav` (ja precarregat per // So del cop letal: reaprofitem `hurt.wav` (ja precarregat per
// `readHurtBlock`). El bloc `death:` no té camp de so propi — // `readHurtBlock`). El bloc `death:` no té camp de so propi —
@@ -1204,19 +1229,46 @@ void Player::render() {
if (state_ == State::DYING && death_animation_complete_) { return; } if (state_ == State::DYING && death_animation_complete_) { return; }
// Mentre el jugador està enlaire, el sprite es desplaça visualment // Mentre el jugador està enlaire, el sprite es desplaça visualment
// cap amunt `jump_z_` píxels. Modifiquem `pos_.y` (la posició entera // `jump_z_` píxels: cap amunt si és positiu (salt), cap avall si és
// negatiu (caiguda per `Hole`, que deixa baixar `jump_z_` a negatius
// fins a `-HOLE_FALL_DEPTH`). Modifiquem `pos_.y` (la posició entera
// de render) abans de cridar el render base, i la restaurem després // de render) abans de cridar el render base, i la restaurem després
// perquè l'estat seguent del frame ho recalcule des de `y_` (la font // perquè l'estat seguent del frame ho recalcule des de `y_` (la font
// de veritat float). El sòl sota el jugador no es pinta des d'ací: // de veritat float). El sòl sota el jugador no es pinta des d'ací:
// això ho fa el GameScene en una passada de sombres a banda. // això ho fa el GameScene en una passada de sombres a banda. El cas
if (jump_z_ <= 0.0F) { // `jump_z_ == 0` (a terra) surt per l'early-return per evitar tocar
AnimatedSprite::render(); // `pos_.y` innecessàriament.
return; // Caiguda per agujero: el sprite es "menja" pel sòl. Fixem un clip
// global a la franja [0, hole_clip_bottom_y_] del canvas (el blitter
// de `Surface` respecta `Rendering::activeClip()`); a mesura que
// `pos_.y - jump_z_` baixa, els píxels del sprite que creuen la
// línia deixen de pintar-se. Fem el push/pop al voltant de tot el
// render del sprite per cobrir tant el cas `jump_z_==0` (caiguda
// just iniciada) com els frames posteriors. `pushClip` ja clamp
// a la mida del canvas, així que un `h` negatiu es traduiria en
// una franja buida i el sprite quedaria invisible — comportament
// correcte si el clip ja ha "tapat" tota la pantalla.
const bool CLIP_HOLE = falling_through_hole_;
if (CLIP_HOLE) {
const auto CANVAS = Rendering::activeCanvas();
Rendering::pushClip({
.x = 0,
.y = 0,
.w = CANVAS.width,
.h = static_cast<int>(hole_clip_bottom_y_),
});
} }
const float SAVED_Y = pos_.y;
pos_.y = SAVED_Y - jump_z_; if (jump_z_ == 0.0F) {
AnimatedSprite::render(); AnimatedSprite::render();
pos_.y = SAVED_Y; } else {
const float SAVED_Y = pos_.y;
pos_.y = SAVED_Y - jump_z_;
AnimatedSprite::render();
pos_.y = SAVED_Y;
}
if (CLIP_HOLE) { Rendering::popClip(); }
} }
void Player::renderShadow() const { void Player::renderShadow() const {
@@ -1224,6 +1276,10 @@ void Player::renderShadow() const {
// que poden cridar `renderShadow` per a tots els jugadors sense // que poden cridar `renderShadow` per a tots els jugadors sense
// discriminar. // discriminar.
if (!isAirborne()) { return; } if (!isAirborne()) { return; }
// Caient per un agujero: el cos s'enfonsa sota el sòl, dibuixar una
// sombra al ras del sòl trenca la il·lusió. Suprimim la sombra fins
// que `enterDying` netegi el flag.
if (falling_through_hole_) { return; }
// Sombra centrada al sprite, recolzada a la línia dels peus reals // Sombra centrada al sprite, recolzada a la línia dels peus reals
// (ignorant `jump_z_`). L'amplada es redueix proporcional a l'altura // (ignorant `jump_z_`). L'amplada es redueix proporcional a l'altura
@@ -1359,6 +1415,8 @@ void Player::respawn(float x, float y) {
being_pushed_ = false; being_pushed_ = false;
jump_z_ = 0.0F; jump_z_ = 0.0F;
jump_vz_ = 0.0F; jump_vz_ = 0.0F;
falling_through_hole_ = false;
hole_clip_bottom_y_ = 0.0F;
landing_stun_remaining_ms_ = 0.0F; landing_stun_remaining_ms_ = 0.0F;
attacking_remaining_ms_ = 0.0F; attacking_remaining_ms_ = 0.0F;
turning_remaining_ms_ = 0.0F; turning_remaining_ms_ = 0.0F;
@@ -1396,6 +1454,54 @@ void Player::disable() {
last_input_cardinal_ = Cardinal::NONE; last_input_cardinal_ = Cardinal::NONE;
} }
void Player::enterHoleFall(float center_x, float center_y, float floor_y) {
// Defensa: només transicionem des de GROUNDED. Saltadors, AIRBORNE,
// HURT, DYING, etc. no han de caure pel forat (entre altres coses,
// perquè `checkPlayerCollisions` ja filtra airborne via altura, però
// afegim el gat ací per coherència amb la resta de transicions).
if (state_ != State::GROUNDED) { return; }
// 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:
// 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);
// Frenem qualsevol inèrcia prèvia: la caiguda ha de ser estàtica al
// centre del forat.
intended_vx_ = 0.0F;
intended_vy_ = 0.0F;
input_vx_ = 0.0F;
input_vy_ = 0.0F;
motion_time_ms_ = 0.0F;
being_pushed_ = false;
setVelX(0.0F);
setVelY(0.0F);
jump_z_ = 0.0F;
jump_vz_ = 0.0F;
state_ = State::AIRBORNE;
falling_through_hole_ = true;
// Línia visual on el clip rect ha de retallar el sprite. Restem
// `SPRITE_FLOOR_OFFSET` perquè la base del sprite té píxels
// transparents — el retall ha de coincidir amb la silueta visible,
// no amb la vora del rect del sprite.
hole_clip_bottom_y_ = floor_y - SPRITE_FLOOR_OFFSET;
// Animació de caiguda: respectem el `facing_` que ja portava el jugador
// (no té sentit reorientar-lo cap al sud només perquè caigui en un
// forat). Com `state_` ja és AIRBORNE, `animationName(false)` resol el
// prefix `jump_` i hi afegeix el sufix cardinal correcte —
// `jump_north`, `jump_south`, `jump_east` o `jump_west` segons el
// facing actual. Si demà cal una animació de "cara de pànic", només
// cal canviar aquí pel `setCurrentAnimation` corresponent.
setCurrentAnimation(animationName(false));
}
void Player::spawnFromAir(float x, float y) { void Player::spawnFromAir(float x, float y) {
// Net total: equival a un `respawn` seguit d'una entrada en l'aire. // Net total: equival a un `respawn` seguit d'una entrada en l'aire.
// Així el flag de gir, l'animació, els flancs del salt i les // Així el flag de gir, l'animació, els flancs del salt i les
+51 -1
View File
@@ -40,6 +40,16 @@ class Player : public AnimatedSprite {
// que decau per inèrcia fins a parar (~2 tiles). // que decau per inèrcia fins a parar (~2 tiles).
// No col·lisiona amb cap altre jugador. Quan // No col·lisiona amb cap altre jugador. Quan
// s'atura, torna a GROUNDED. // s'atura, torna a GROUNDED.
// Causa de la mort actual. La passa el caller a `enterDying(cause)` per
// triar la presentació visual (`death_<key>` al YAML del player). El
// default `GENERIC` cobreix tots els callsites antics (mòmies, etc.)
// sense haver de tocar-los; els nous (caiguda per `Hole`, en el futur
// pinchos / timeout / etc.) passen la seua causa explícita.
enum class DeathCause : std::uint8_t {
GENERIC, // mort estàndard — animació `death_spinning`
HOLE, // caiguda per agujero — animació `death_hole`
};
enum class State : std::uint8_t { enum class State : std::uint8_t {
GROUNDED, GROUNDED,
AIRBORNE, AIRBORNE,
@@ -226,6 +236,21 @@ class Player : public AnimatedSprite {
return state_ == State::GROUNDED || state_ == State::LANDING_STUN; return state_ == State::GROUNDED || state_ == State::LANDING_STUN;
} }
// El jugador està caient per un agujero (`Hole`): subestat dins
// d'AIRBORNE amb gravetat aplicada SENSE clamp a 0 — `jump_z_` baixa
// a negatius fins a `HOLE_FALL_DEPTH`, llavors `enterDying()`. Mentre
// dura, `checkPlayerCollisions` salta aquest jugador (cap més hazard
// l'ha de tocar). Es neteja a `enterDying()` i a `respawn()`.
[[nodiscard]] auto isFallingThroughHole() const -> bool { return falling_through_hole_; }
// Activa la caída per agujero: snap del jugador al centre (cx, cy) en
// coordenades absolutes del canvas, transició a AIRBORNE amb
// `jump_z_=0` i `falling_through_hole_=true`, i arma el clip rect
// visual a partir de `floor_y` (Y de la base del tile del forat,
// canvas píxels). Desactiva input direccional reusant la branca
// AIRBORNE existent. No-op si el jugador no està a GROUNDED.
void enterHoleFall(float center_x, float center_y, float floor_y);
// Despawn lògic: el jugador acaba de creuar la porta de sortida. // Despawn lògic: el jugador acaba de creuar la porta de sortida.
// Idempotent — només la primera crida fa la transició; les // Idempotent — només la primera crida fa la transició; les
// posteriors són no-op. La publicació del `player_exited` al bus // posteriors són no-op. La publicació del `player_exited` al bus
@@ -254,7 +279,7 @@ class Player : public AnimatedSprite {
// momia mentre la roda no la reinicia). Quan l'animació acaba i // momia mentre la roda no la reinicia). Quan l'animació acaba i
// `respawn_delay_ms` expira, `respawn(spawn_x_, spawn_y_)` torna a // `respawn_delay_ms` expira, `respawn(spawn_x_, spawn_y_)` torna a
// GROUNDED i `lives_remaining_` decreix en 1 (pot anar a negatius). // GROUNDED i `lives_remaining_` decreix en 1 (pot anar a negatius).
void enterDying(); void enterDying(DeathCause cause = DeathCause::GENERIC);
// Coordenades del spawn de respawn (cantó superior-esquerra del // Coordenades del spawn de respawn (cantó superior-esquerra del
// sprite, mateixa convenció que `respawn(x, y)`). El GameScene les // sprite, mateixa convenció que `respawn(x, y)`). El GameScene les
@@ -655,6 +680,31 @@ class Player : public AnimatedSprite {
float jump_z_{0.0F}; float jump_z_{0.0F};
float jump_vz_{0.0F}; float jump_vz_{0.0F};
// Subestat dins d'AIRBORNE: `enterHoleFall` el posa a true; mentre dura,
// `integrateJump` no clampa `jump_z_` a 0 — deixa baixar a negatius i
// dispara `enterDying()` quan creua `HOLE_FALL_DEPTH`. Es neteja a
// `enterDying()` i `respawn()`.
bool falling_through_hole_{false};
// Y absoluta (canvas píxels) per sota de la qual el `Player::render`
// retalla el sprite mentre `falling_through_hole_=true`. La fixa
// `enterHoleFall` a `floor_y - SPRITE_FLOOR_OFFSET` i la neteja
// `respawn`. Sense ús quan no estem caient per un forat.
float hole_clip_bottom_y_{0.0F};
// Profunditat (en píxels per sota del sòl) que ha de superar el jugador
// mentre cau per un agujero abans que `integrateJump` el mate. Constant
// per ara (ajustable a posteriori si el feel no és bo).
static constexpr float HOLE_FALL_DEPTH = 20.0F;
// Píxels transparents a la base del sprite del jugador (16×32). El
// sprite "camina pel mig del passadís en falsa perspectiva", així que
// la silueta no toca la vora inferior del rect del sprite — hi queda
// un marge transparent. Quan armem el clip rect del forat, restem
// aquest offset a la `floor_y` perquè la línia de retall coincideixi
// visualment amb la base del tile, no amb la vora del sprite.
static constexpr float SPRITE_FLOOR_OFFSET = 3.0F;
// Temps restant de stun d'aterratge (ms). Mentre > 0, l'estat és // Temps restant de stun d'aterratge (ms). Mentre > 0, l'estat és
// 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};
+18
View File
@@ -0,0 +1,18 @@
// Stubs de Player per als binaris `test_entities_smoke` i `test_whip_attack`.
//
// `Hole` referencia `Player::getHitBox()` i `Player::enterHoleFall()` dins
// de `onPlayerContact` (camí no exercit pels tests stand-alone). Linkar
// `player.cpp` arrossegaria audio/gamepad/input/screen/cache, una explosió
// de dependències que els smokes intenten evitar — mateixa decisió que
// `whip_attack_player_stubs.cpp`. Aquests stubs proporcionen els símbols
// com a no-op perquè T1/T7 puguin fer `factory.create("hole", ...)`.
#include <SDL3/SDL.h>
#include "game/entities/player.hpp"
auto Player::getHitBox() const -> SDL_FRect {
return SDL_FRect{0.0F, 0.0F, 0.0F, 0.0F};
}
void Player::enterHoleFall(float /*center_x*/, float /*center_y*/, float /*floor_y*/) {
}
+3 -2
View File
@@ -44,6 +44,7 @@
#include "game/entities/animation_component.hpp" #include "game/entities/animation_component.hpp"
#include "game/entities/concrete/door.hpp" #include "game/entities/concrete/door.hpp"
#include "game/entities/concrete/floor_trap.hpp" #include "game/entities/concrete/floor_trap.hpp"
#include "game/entities/concrete/hole.hpp"
#include "game/entities/concrete/mummy.hpp" #include "game/entities/concrete/mummy.hpp"
#include "game/entities/concrete/rolling_rock.hpp" #include "game/entities/concrete/rolling_rock.hpp"
#include "game/entities/concrete/sarcophagus.hpp" #include "game/entities/concrete/sarcophagus.hpp"
@@ -74,7 +75,7 @@ namespace {
const std::vector<std::string> TYPES = { const std::vector<std::string> TYPES = {
"torch", "sarcophagus", "door", "mummy", "torch", "sarcophagus", "door", "mummy",
"rolling_rock", "wall_trap", "floor_trap", "rolling_rock", "wall_trap", "floor_trap", "hole",
}; };
for (const auto& type : TYPES) { for (const auto& type : TYPES) {
fkyaml::node tmpl = EntityLoader::loadTemplate(type); fkyaml::node tmpl = EntityLoader::loadTemplate(type);
@@ -251,7 +252,7 @@ namespace {
std::cout << "[T7] plantilles YAML\n"; std::cout << "[T7] plantilles YAML\n";
const std::vector<std::string> TYPES = { const std::vector<std::string> TYPES = {
"torch", "sarcophagus", "door", "mummy", "torch", "sarcophagus", "door", "mummy",
"rolling_rock", "wall_trap", "floor_trap", "rolling_rock", "wall_trap", "floor_trap", "hole",
}; };
for (const auto& type : TYPES) { for (const auto& type : TYPES) {
try { try {