discriminació
Reverteix la separació en sacs introduïda al commit 62f4b85 (rama
anterior). Amb els virtuals afegits al commit precedent
(`onWhipHit`, `onJustSpawned`, `shouldReceiveContact`, `onRemoved`),
ja no cal separar `map_entities_` i `actors_`: cada entitat decideix
internament què fa, el manager itera un sol vector i confia en el
dispatch virtual.
Canvis:
- `entities_` torna a ser el contenidor únic. Es desfà el dispatch
`dynamic_cast<DynamicHazard*>` que `add()` feia per enrutar.
- `addSarcophagus`, `removeEntity`, `update`, `notifyPlayerTilePainted`,
`findFloorTriggerAt`, `renderAll`, `reload` simplificats: un sol
bucle, sense distingir categories.
- `checkPlayerCollisions` només recorre `entities_` cridant
`shouldReceiveContact` polimòrfic — la lògica de "saltar SPAWN"
viu a `DynamicHazard::shouldReceiveContact`.
- `getMapEntities()` i `getActors()` desapareixen. Es manté
`getEntities()` com a accessor únic.
- `WhipAttack::applyDamage` itera `getEntities()` cridant `onWhipHit`
polimòrfic. Les entitats no-whippables retornen false (cost: una
crida virtual buida) i el callsite no discrimina tipus.
- `GameScene::render` mergeja entities + players (dos transforms en
comptes de tres) per al z-order.
`pending_actors_` es manté: és un concepte ortogonal — entitats que
encara no s'han instanciat al món fins que GameScene les "spawneja"
al text-exit de ROOM_INTRO.
Beneficis del nou disseny:
- Cap dynamic_cast dispers per a la lògica de joc (només queden els
inevitables: validació de factory i ruteig de la Door).
- Afegir un nou enemic implica `class X : public DynamicHazard` i
override dels virtuals rellevants — res al manager.
- Si un sarc, antorxa, o decoració futura volgués reaccionar al
fueteig (trencar-se, encendre's), basta amb un `onWhipHit` override
i no cal tocar `WhipAttack::applyDamage`.
356 lines
14 KiB
C++
356 lines
14 KiB
C++
#include "game/entities/concrete/whip_attack.hpp"
|
||
|
||
#include <algorithm>
|
||
#include <filesystem>
|
||
#include <string>
|
||
#include <vector>
|
||
|
||
#include "game/entities/concrete/mummy.hpp"
|
||
#include "game/entities/entity_factory.hpp"
|
||
#include "game/entities/entity_manager.hpp"
|
||
#include "game/entities/player.hpp"
|
||
#include "game/gameplay_config.hpp"
|
||
#include "game/map/map.hpp"
|
||
|
||
namespace {
|
||
|
||
// Llig una entrada `sprites.<dir>: { path, size: [w, h] }` del YAML.
|
||
// Si manquen camps, retorna un DirSprite buit (queda invalidat al
|
||
// configure() per `surface_key.empty()`).
|
||
auto readDirSprite(const fkyaml::node& s) -> WhipAttack::DirSprite {
|
||
WhipAttack::DirSprite sprite;
|
||
if (s.contains("path")) {
|
||
const auto REL = s["path"].get_value<std::string>();
|
||
sprite.surface_key = std::filesystem::path(REL).filename().string();
|
||
}
|
||
if (s.contains("size")) {
|
||
const auto& sz = s["size"];
|
||
if (sz.is_sequence() && sz.size() == 2) {
|
||
sprite.frame_w = static_cast<float>(sz[0].get_value<int>());
|
||
sprite.frame_h = static_cast<float>(sz[1].get_value<int>());
|
||
}
|
||
}
|
||
return sprite;
|
||
}
|
||
|
||
// Construeix l'`AnimationComponent` del swing a partir del bloc
|
||
// `animations.swing`. Convenció igual que la resta d'entitats:
|
||
// - `frames` : llista d'índexs dins l'spritesheet (obligatori).
|
||
// - `frame_ms` : durada uniforme per frame (ms). Alternativament `fps`.
|
||
// - `loop` : per defecte true; al látigo el volem false (mor en
|
||
// completar).
|
||
auto buildSwingAnim(const fkyaml::node& yaml, std::vector<int>& active_out)
|
||
-> AnimationComponent {
|
||
std::vector<int> frames = {0};
|
||
float fps = 8.0F;
|
||
bool loop = false;
|
||
if (yaml.contains("animations") && yaml["animations"].contains("swing")) {
|
||
const auto& sw = yaml["animations"]["swing"];
|
||
if (sw.contains("frames")) {
|
||
frames.clear();
|
||
for (const auto& f : sw["frames"]) {
|
||
// cppcheck-suppress useStlAlgorithm
|
||
frames.push_back(f.get_value<int>());
|
||
}
|
||
if (frames.empty()) { frames.push_back(0); }
|
||
}
|
||
if (sw.contains("frame_ms")) {
|
||
const auto MS = static_cast<float>(sw["frame_ms"].get_value<int>());
|
||
if (MS > 0.0F) { fps = 1000.0F / MS; }
|
||
} else if (sw.contains("fps")) {
|
||
fps = sw["fps"].get_value<float>();
|
||
}
|
||
if (sw.contains("loop")) { loop = sw["loop"].get_value<bool>(); }
|
||
if (sw.contains("active_frames")) {
|
||
for (const auto& f : sw["active_frames"]) {
|
||
// cppcheck-suppress useStlAlgorithm
|
||
active_out.push_back(f.get_value<int>());
|
||
}
|
||
}
|
||
}
|
||
return {frames, fps, loop};
|
||
}
|
||
|
||
} // namespace
|
||
|
||
WhipAttack::WhipAttack(const fkyaml::node& yaml) {
|
||
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("whip_attack");
|
||
collides_ = false;
|
||
interactable_ = false;
|
||
|
||
if (yaml.contains("sprites")) {
|
||
const auto& s = yaml["sprites"];
|
||
if (s.contains("north")) { sprite_north_ = readDirSprite(s["north"]); }
|
||
if (s.contains("south")) { sprite_south_ = readDirSprite(s["south"]); }
|
||
if (s.contains("east")) { sprite_east_ = readDirSprite(s["east"]); }
|
||
if (s.contains("west")) { sprite_west_ = readDirSprite(s["west"]); }
|
||
}
|
||
|
||
anim_swing_ = buildSwingAnim(yaml, active_frames_);
|
||
anim_ = anim_swing_;
|
||
}
|
||
|
||
void WhipAttack::configure(Direction dir, const SDL_FRect& owner_box, float range_px) {
|
||
direction_ = dir;
|
||
range_px_ = range_px;
|
||
|
||
// Draw rect (= pixel_rect_): cobreix tot el sprite del látigo segons
|
||
// la taula d'offsets del disseny:
|
||
// E: (player.x - 16, player.y ), 80×32
|
||
// W: (player.x - 48, player.y ), 80×32
|
||
// N: (player.x, player.y - 48 ), 16×96
|
||
// S: (player.x, player.y - 16 ), 16×96
|
||
const auto& chosen = spriteFor(dir);
|
||
SDL_FRect draw{0.0F, 0.0F, chosen.frame_w, chosen.frame_h};
|
||
switch (dir) {
|
||
case Direction::EAST:
|
||
draw.x = owner_box.x - 16.0F;
|
||
draw.y = owner_box.y;
|
||
break;
|
||
case Direction::WEST:
|
||
draw.x = owner_box.x - 48.0F;
|
||
draw.y = owner_box.y;
|
||
break;
|
||
case Direction::NORTH:
|
||
draw.x = owner_box.x;
|
||
draw.y = owner_box.y - 48.0F;
|
||
break;
|
||
case Direction::SOUTH:
|
||
draw.x = owner_box.x;
|
||
draw.y = owner_box.y - 16.0F;
|
||
break;
|
||
}
|
||
setPixelRect(draw);
|
||
|
||
// Damage rect: 48×16 (E/W) o 16×48 (N/S) al nivell dels peus del
|
||
// jugador (els últims 16 px del seu pixel_rect — convenció estàndard
|
||
// del player AEE), pegat al borde en la direcció d'atac.
|
||
// computeDamageRect rep la cantonada del rect MÀXIM (sense retallar)
|
||
// i recorre internament la línia per topar amb el primer bloquejador.
|
||
const float COLLISION_TOP_Y = owner_box.y + owner_box.h - 16.0F;
|
||
const float OWNER_RIGHT = owner_box.x + owner_box.w;
|
||
const float OWNER_BOTTOM = owner_box.y + owner_box.h;
|
||
float spawn_x = 0.0F;
|
||
float spawn_y = 0.0F;
|
||
switch (dir) {
|
||
case Direction::EAST:
|
||
spawn_x = OWNER_RIGHT;
|
||
spawn_y = COLLISION_TOP_Y;
|
||
break;
|
||
case Direction::WEST:
|
||
spawn_x = owner_box.x - range_px;
|
||
spawn_y = COLLISION_TOP_Y;
|
||
break;
|
||
case Direction::NORTH:
|
||
spawn_x = owner_box.x;
|
||
spawn_y = COLLISION_TOP_Y - range_px;
|
||
break;
|
||
case Direction::SOUTH:
|
||
spawn_x = owner_box.x;
|
||
spawn_y = OWNER_BOTTOM;
|
||
break;
|
||
}
|
||
computeDamageRect(dir, spawn_x, spawn_y, range_px);
|
||
|
||
// Snapshot del feet_y del jugador. La convenció estàndard del Player
|
||
// AEE té el seu feet_y a la base inferior del pixel_rect (collision
|
||
// 16×16 als últims 16 px del sprite 16×32), així que owner_box.y +
|
||
// owner_box.h reprodueix `Player::getFeetY()` exactament.
|
||
owner_feet_y_ = owner_box.y + owner_box.h;
|
||
|
||
if (anim_.has_value()) { anim_->reset(); }
|
||
}
|
||
|
||
auto WhipAttack::getFeetY() const -> float {
|
||
return owner_feet_y_;
|
||
}
|
||
|
||
void WhipAttack::setWorldContext(EntityManager* manager, const std::vector<Player*>* players) {
|
||
entity_manager_ = manager;
|
||
players_ = players;
|
||
}
|
||
|
||
void WhipAttack::computeDamageRect(Direction dir, float spawn_x, float spawn_y, float range_px) {
|
||
// Caminem píxel a píxel des de la vora del jugador en la direcció
|
||
// d'atac, fins a topar amb un mur o sarc, o esgotar `range_px`.
|
||
// L'amplada perpendicular és `tile_size` (els passadissos del joc
|
||
// tenen exactament aquesta mida i l'atac és lateral).
|
||
//
|
||
// Si el tweak `whip.damage_pierces_walls` està actiu, la hitbox
|
||
// ignora els sarcòfags (passa a través del bloc complet) però
|
||
// continua respectant el `insidePlayfield`: fora de la sala no hi
|
||
// ha res a colpejar i ampliar-ho generaria visuals/edge cases
|
||
// estranys. El mur extern, doncs, sempre retalla.
|
||
const auto TILE = static_cast<float>(Map::tile_size);
|
||
const int MAX_PX = static_cast<int>(std::max(0.0F, range_px));
|
||
const auto MAX = static_cast<float>(MAX_PX);
|
||
const bool PIERCES = GameplayConfig::get().whipDamagePiercesWalls();
|
||
float length = 0.0F;
|
||
|
||
auto try_probe = [&](float l) -> bool {
|
||
SDL_FRect probe{0.0F, 0.0F, 0.0F, 0.0F};
|
||
switch (dir) {
|
||
case Direction::EAST:
|
||
probe = {.x = spawn_x, .y = spawn_y, .w = l, .h = TILE};
|
||
break;
|
||
case Direction::WEST:
|
||
// Per a W, la cara "del jugador" és la dreta; com creix
|
||
// `l`, la vora esquerra retrocedeix. spawn_x apunta al
|
||
// límit esquerre amb l = MAX (cas no retallat).
|
||
probe = {.x = spawn_x + (MAX - l), .y = spawn_y, .w = l, .h = TILE};
|
||
break;
|
||
case Direction::SOUTH:
|
||
probe = {.x = spawn_x, .y = spawn_y, .w = TILE, .h = l};
|
||
break;
|
||
case Direction::NORTH:
|
||
probe = {.x = spawn_x, .y = spawn_y + (MAX - l), .w = TILE, .h = l};
|
||
break;
|
||
}
|
||
if (!Map::insidePlayfield(probe)) { return false; }
|
||
if (PIERCES) { return true; }
|
||
return !Map::collides(probe);
|
||
};
|
||
|
||
for (int i = 1; i <= MAX_PX; ++i) {
|
||
const auto L = static_cast<float>(i);
|
||
if (try_probe(L)) {
|
||
length = L;
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
|
||
switch (dir) {
|
||
case Direction::EAST:
|
||
damage_rect_ = {.x = spawn_x, .y = spawn_y, .w = length, .h = TILE};
|
||
return;
|
||
case Direction::WEST:
|
||
damage_rect_ = {.x = spawn_x + (MAX - length), .y = spawn_y, .w = length, .h = TILE};
|
||
return;
|
||
case Direction::SOUTH:
|
||
damage_rect_ = {.x = spawn_x, .y = spawn_y, .w = TILE, .h = length};
|
||
return;
|
||
case Direction::NORTH:
|
||
damage_rect_ = {.x = spawn_x, .y = spawn_y + (MAX - length), .w = TILE, .h = length};
|
||
return;
|
||
}
|
||
}
|
||
|
||
auto WhipAttack::getSurfaceKey() const -> const std::string& {
|
||
return spriteFor(direction_).surface_key;
|
||
}
|
||
|
||
auto WhipAttack::spriteFor(Direction dir) const -> const DirSprite& {
|
||
switch (dir) {
|
||
case Direction::NORTH:
|
||
return sprite_north_;
|
||
case Direction::SOUTH:
|
||
return sprite_south_;
|
||
case Direction::EAST:
|
||
return sprite_east_;
|
||
case Direction::WEST:
|
||
return sprite_west_;
|
||
}
|
||
return sprite_east_;
|
||
}
|
||
|
||
void WhipAttack::update(float delta_time) {
|
||
Entity::update(delta_time);
|
||
if (isFrameActive()) { applyDamage(); }
|
||
if (anim_.has_value() && anim_->completed()) { markDead(); }
|
||
}
|
||
|
||
namespace {
|
||
|
||
// (El mapping WhipAttack::Direction → Mummy::Direction viu ara a
|
||
// mummy.cpp, on s'usa des de `Mummy::onWhipHit`. Aquí només queda el
|
||
// mapping a Player::Cardinal — usat per al friendly-fire entre
|
||
// jugadors, que no és polimòrfic via Entity.)
|
||
|
||
auto whipDirToPlayerCardinal(WhipAttack::Direction d) -> Player::Cardinal {
|
||
switch (d) {
|
||
case WhipAttack::Direction::NORTH:
|
||
return Player::Cardinal::NORTH;
|
||
case WhipAttack::Direction::SOUTH:
|
||
return Player::Cardinal::SOUTH;
|
||
case WhipAttack::Direction::EAST:
|
||
return Player::Cardinal::EAST;
|
||
case WhipAttack::Direction::WEST:
|
||
return Player::Cardinal::WEST;
|
||
}
|
||
return Player::Cardinal::NONE;
|
||
}
|
||
|
||
auto playerIsTargetable(const Player* p) -> bool {
|
||
if (p == nullptr) { return false; }
|
||
const auto STATE = p->getState();
|
||
return STATE != Player::State::HURT && STATE != Player::State::EXITED && STATE != Player::State::DISABLED;
|
||
}
|
||
|
||
// Tracta una entitat candidata: si la seva caixa solapa amb
|
||
// `damage_rect`, delega la decisió a `entity->onWhipHit(attack)`.
|
||
// Cada entitat decideix què fer (Mummy entra HURT, Torch retorna
|
||
// false sense efecte, RollingRock no override → no-op). El retorn
|
||
// true marca el set `already_hit` per evitar segons impactes dins del
|
||
// mateix swing. Polimorfisme net — sense dynamic_cast.
|
||
void hitEntityIfApplicable(
|
||
Entity* entity,
|
||
const SDL_FRect& damage_rect,
|
||
WhipAttack* attack,
|
||
std::unordered_set<void*>& already_hit) {
|
||
if (entity == nullptr || entity->isDead()) { return; }
|
||
if (already_hit.contains(static_cast<void*>(entity))) { return; }
|
||
const auto BOX = entity->getCollisionRect();
|
||
if (!SDL_HasRectIntersectionFloat(&damage_rect, &BOX)) { return; }
|
||
if (entity->onWhipHit(attack)) {
|
||
already_hit.insert(static_cast<void*>(entity));
|
||
}
|
||
}
|
||
|
||
void hitPlayerIfApplicable(
|
||
Player* p,
|
||
const Player* owner,
|
||
const SDL_FRect& damage_rect,
|
||
WhipAttack::Direction dir,
|
||
std::unordered_set<void*>& already_hit) {
|
||
if (p == owner || !playerIsTargetable(p)) { return; }
|
||
// Tweak `combat.friendly_fire`: si està desactivat, els altres
|
||
// jugadors són immunes al látigo aliat. L'owner ja queda exclòs
|
||
// per la primera guarda; aquest filtre cobreix només cross-player.
|
||
if (!GameplayConfig::get().friendlyFire()) { return; }
|
||
if (already_hit.contains(static_cast<void*>(p))) { return; }
|
||
const auto BOX = p->getBaseBox();
|
||
if (!SDL_HasRectIntersectionFloat(&damage_rect, &BOX)) { return; }
|
||
p->enterHurt(whipDirToPlayerCardinal(dir));
|
||
already_hit.insert(static_cast<void*>(p));
|
||
}
|
||
|
||
} // namespace
|
||
|
||
void WhipAttack::applyDamage() {
|
||
if (entity_manager_ != nullptr) {
|
||
// Iterem totes les entitats. La discriminació la fa cada una via
|
||
// `onWhipHit`: Mummy entra HURT si és whippable, la resta retorna
|
||
// false (no-op). El cost d'iterar antorxes/sarcs/forats és una
|
||
// crida virtual buida cada una — menyspreable comparat amb la
|
||
// claredat del callsite. Si demà un sarc vol trencar-se al
|
||
// fueteig, només cal override de `onWhipHit` allà.
|
||
for (auto* entity : entity_manager_->getEntities()) {
|
||
hitEntityIfApplicable(entity, damage_rect_, this, already_hit_);
|
||
}
|
||
}
|
||
if (players_ != nullptr) {
|
||
for (auto* p : *players_) {
|
||
hitPlayerIfApplicable(p, owner_, damage_rect_, direction_, already_hit_);
|
||
}
|
||
}
|
||
}
|
||
|
||
auto WhipAttack::isFrameActive() const -> bool {
|
||
if (!anim_.has_value()) { return false; }
|
||
const int FRAME = anim_->currentFrame();
|
||
return std::ranges::any_of(active_frames_, [FRAME](int f) { return f == FRAME; });
|
||
}
|
||
|
||
REGISTER_ENTITY("whip_attack", WhipAttack)
|