feat(whip): aplicació de dany amb retall de hitbox, alreadyHit i friendly fire
This commit is contained in:
1 parent
d208ba02df
commit
df60709e6d
5 files changed
+217
-6
No files matched your search
+11
-2
@@ -33,7 +33,10 @@ configure_file(${CMAKE_SOURCE_DIR}/source/version.h.in ${CMAKE_BINARY_DIR}/versi
|
|||||||
# Sources comuns del sistema d'entitats: s'afegeixen tant a l'executable
|
# Sources comuns del sistema d'entitats: s'afegeixen tant a l'executable
|
||||||
# principal com al smoke test. Mantenir-los en variable separada evita
|
# principal com al smoke test. Mantenir-los en variable separada evita
|
||||||
# duplicació entre els dos targets.
|
# duplicació entre els dos targets.
|
||||||
set(ENTITY_SOURCES
|
# Entitats sense dependència directa amb Player.cpp: les enllacen tant
|
||||||
|
# l'executable principal com el test_entities_smoke (que evita arrossegar
|
||||||
|
# l'stack del jugador i les seues dependències pesades).
|
||||||
|
set(ENTITY_SOURCES_BASE
|
||||||
source/game/entities/entity.cpp
|
source/game/entities/entity.cpp
|
||||||
source/game/entities/animation_component.cpp
|
source/game/entities/animation_component.cpp
|
||||||
source/game/entities/event_bus.cpp
|
source/game/entities/event_bus.cpp
|
||||||
@@ -48,6 +51,12 @@ set(ENTITY_SOURCES
|
|||||||
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/dust.cpp
|
source/game/entities/concrete/dust.cpp
|
||||||
|
)
|
||||||
|
|
||||||
|
# Entitats que criden directament símbols de Player (com WhipAttack via
|
||||||
|
# `enterHurt`/`getBaseBox`): només van a l'executable principal.
|
||||||
|
set(ENTITY_SOURCES
|
||||||
|
${ENTITY_SOURCES_BASE}
|
||||||
source/game/entities/concrete/whip_attack.cpp
|
source/game/entities/concrete/whip_attack.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -339,7 +348,7 @@ if(BUILD_TESTS)
|
|||||||
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
|
||||||
${ENTITY_SOURCES}
|
${ENTITY_SOURCES_BASE}
|
||||||
)
|
)
|
||||||
target_include_directories(test_entities_smoke PRIVATE
|
target_include_directories(test_entities_smoke PRIVATE
|
||||||
"${CMAKE_SOURCE_DIR}/source"
|
"${CMAKE_SOURCE_DIR}/source"
|
||||||
|
|||||||
@@ -5,7 +5,11 @@
|
|||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
#include "game/entities/concrete/mummy.hpp"
|
||||||
#include "game/entities/entity_factory.hpp"
|
#include "game/entities/entity_factory.hpp"
|
||||||
|
#include "game/entities/entity_manager.hpp"
|
||||||
|
#include "game/entities/player.hpp"
|
||||||
|
#include "game/map/map.hpp"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
@@ -92,9 +96,73 @@ void WhipAttack::configure(Direction dir, float spawn_x, float spawn_y, float ra
|
|||||||
const auto& chosen = spriteFor(dir);
|
const auto& chosen = spriteFor(dir);
|
||||||
setPixelRect({.x = spawn_x, .y = spawn_y, .w = chosen.frame_w, .h = chosen.frame_h});
|
setPixelRect({.x = spawn_x, .y = spawn_y, .w = chosen.frame_w, .h = chosen.frame_h});
|
||||||
|
|
||||||
|
computeDamageRect(dir, spawn_x, spawn_y, range_px);
|
||||||
|
|
||||||
if (anim_.has_value()) { anim_->reset(); }
|
if (anim_.has_value()) { anim_->reset(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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).
|
||||||
|
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);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
return Map::insidePlayfield(probe) && !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& {
|
auto WhipAttack::getSurfaceKey() const -> const std::string& {
|
||||||
return spriteFor(direction_).surface_key;
|
return spriteFor(direction_).surface_key;
|
||||||
}
|
}
|
||||||
@@ -115,9 +183,93 @@ auto WhipAttack::spriteFor(Direction dir) const -> const DirSprite& {
|
|||||||
|
|
||||||
void WhipAttack::update(float delta_time) {
|
void WhipAttack::update(float delta_time) {
|
||||||
Entity::update(delta_time);
|
Entity::update(delta_time);
|
||||||
|
if (isFrameActive()) { applyDamage(); }
|
||||||
if (anim_.has_value() && anim_->completed()) { markDead(); }
|
if (anim_.has_value() && anim_->completed()) { markDead(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
auto whipDirToMummyDir(WhipAttack::Direction d) -> Mummy::Direction {
|
||||||
|
switch (d) {
|
||||||
|
case WhipAttack::Direction::NORTH:
|
||||||
|
return Mummy::Direction::UP;
|
||||||
|
case WhipAttack::Direction::SOUTH:
|
||||||
|
return Mummy::Direction::DOWN;
|
||||||
|
case WhipAttack::Direction::EAST:
|
||||||
|
return Mummy::Direction::RIGHT;
|
||||||
|
case WhipAttack::Direction::WEST:
|
||||||
|
return Mummy::Direction::LEFT;
|
||||||
|
}
|
||||||
|
return Mummy::Direction::NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 mòmia candidata: si és whippable i la seua caja solapa
|
||||||
|
// amb `damage_rect`, la posa a HURT i la marca al set. Extret per
|
||||||
|
// acotar la complexitat cognitiva d'`applyDamage` (clang-tidy llindar).
|
||||||
|
void hitMummyIfApplicable(
|
||||||
|
Entity* e,
|
||||||
|
const SDL_FRect& damage_rect,
|
||||||
|
WhipAttack::Direction dir,
|
||||||
|
std::unordered_set<void*>& already_hit) {
|
||||||
|
if (e == nullptr || e->isDead()) { return; }
|
||||||
|
if (already_hit.contains(static_cast<void*>(e))) { return; }
|
||||||
|
auto* mummy = dynamic_cast<Mummy*>(e);
|
||||||
|
if (mummy == nullptr || !mummy->isWhippable()) { return; }
|
||||||
|
const auto BOX = mummy->getCollisionRect();
|
||||||
|
if (!SDL_HasRectIntersectionFloat(&damage_rect, &BOX)) { return; }
|
||||||
|
mummy->enterHurt(whipDirToMummyDir(dir));
|
||||||
|
already_hit.insert(static_cast<void*>(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
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; }
|
||||||
|
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) {
|
||||||
|
for (auto* e : entity_manager_->getEntities()) {
|
||||||
|
hitMummyIfApplicable(e, damage_rect_, direction_, already_hit_);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (players_ != nullptr) {
|
||||||
|
for (auto* p : *players_) {
|
||||||
|
hitPlayerIfApplicable(p, owner_, damage_rect_, direction_, already_hit_);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
auto WhipAttack::isFrameActive() const -> bool {
|
auto WhipAttack::isFrameActive() const -> bool {
|
||||||
if (!anim_.has_value()) { return false; }
|
if (!anim_.has_value()) { return false; }
|
||||||
const int FRAME = anim_->currentFrame();
|
const int FRAME = anim_->currentFrame();
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <SDL3/SDL.h>
|
||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <set>
|
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <unordered_set>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "external/fkyaml_node.hpp"
|
#include "external/fkyaml_node.hpp"
|
||||||
@@ -10,6 +12,8 @@
|
|||||||
#include "game/entities/attack.hpp"
|
#include "game/entities/attack.hpp"
|
||||||
|
|
||||||
class Entity;
|
class Entity;
|
||||||
|
class EntityManager;
|
||||||
|
class Player;
|
||||||
|
|
||||||
// Atac de látigo: spawnejat pel jugador en entrar a `ATTACKING`. Viu la
|
// Atac de látigo: spawnejat pel jugador en entrar a `ATTACKING`. Viu la
|
||||||
// durada de la seua animació (`swing`) i s'autoelimina al final.
|
// durada de la seua animació (`swing`) i s'autoelimina al final.
|
||||||
@@ -51,10 +55,20 @@ class WhipAttack : public Attack {
|
|||||||
// Configura l'estat runtime de la instància. Cal cridar-lo abans del
|
// Configura l'estat runtime de la instància. Cal cridar-lo abans del
|
||||||
// primer `update`. Selecciona la surface i la mida del pixel_rect_
|
// primer `update`. Selecciona la surface i la mida del pixel_rect_
|
||||||
// segons direcció, i posa la cantonada superior-esquerra de la
|
// segons direcció, i posa la cantonada superior-esquerra de la
|
||||||
// hitbox a (spawn_x, spawn_y).
|
// hitbox a (spawn_x, spawn_y). També calcula la `damage_rect_`
|
||||||
|
// retallada contra murs i sarcs (mai sortiria del playfield ni
|
||||||
|
// travessaria un sarc).
|
||||||
void configure(Direction dir, float spawn_x, float spawn_y, float range_px);
|
void configure(Direction dir, float spawn_x, float spawn_y, float range_px);
|
||||||
|
|
||||||
// Avança animació; quan completa, marca dead.
|
// Injecta el context que necessita la fase de dany: punter a
|
||||||
|
// l'`EntityManager` (per iterar Hazards) i a la llista de jugadors
|
||||||
|
// (per al friendly fire). Si no es crida, els frames actius no
|
||||||
|
// apliquen dany — el whip queda merament visual. Cridar abans del
|
||||||
|
// primer `update` (típicament just després de `configure`).
|
||||||
|
void setWorldContext(EntityManager* manager, const std::vector<Player*>* players);
|
||||||
|
|
||||||
|
// Avança animació; durant frames actius, aplica dany; quan
|
||||||
|
// l'animació es completa, marca dead.
|
||||||
void update(float delta_time) override;
|
void update(float delta_time) override;
|
||||||
|
|
||||||
[[nodiscard]] auto getDirection() const -> Direction { return direction_; }
|
[[nodiscard]] auto getDirection() const -> Direction { return direction_; }
|
||||||
@@ -71,6 +85,11 @@ class WhipAttack : public Attack {
|
|||||||
// Llargada de la hitbox abans de retallar contra murs/sarcs (en píxels).
|
// Llargada de la hitbox abans de retallar contra murs/sarcs (en píxels).
|
||||||
[[nodiscard]] auto getRangePx() const -> float { return range_px_; }
|
[[nodiscard]] auto getRangePx() const -> float { return range_px_; }
|
||||||
|
|
||||||
|
// Hitbox efectiva de dany (ja retallada pel primer bloquejador en la
|
||||||
|
// línia d'atac). Exposada per als smoke tests; en producció només la
|
||||||
|
// consumeix la pròpia `applyDamage`.
|
||||||
|
[[nodiscard]] auto getDamageRect() const -> SDL_FRect { return damage_rect_; }
|
||||||
|
|
||||||
// Clau al `Resource::Cache` per a la surface de la direcció actual
|
// Clau al `Resource::Cache` per a la surface de la direcció actual
|
||||||
// (la que ha triat `configure()`). El caller (típicament `GameScene`)
|
// (la que ha triat `configure()`). El caller (típicament `GameScene`)
|
||||||
// resol la surface i la passa via `setSurface()`. Aquesta separació
|
// resol la surface i la passa via `setSurface()`. Aquesta separació
|
||||||
@@ -81,11 +100,31 @@ class WhipAttack : public Attack {
|
|||||||
private:
|
private:
|
||||||
[[nodiscard]] auto spriteFor(Direction dir) const -> const DirSprite&;
|
[[nodiscard]] auto spriteFor(Direction dir) const -> const DirSprite&;
|
||||||
|
|
||||||
|
// Calcula la `damage_rect_` final: retalla la longitud de l'atac
|
||||||
|
// contra el primer bloquejador (mur extern del playfield o sarc) en
|
||||||
|
// la línia d'atac. Implementació iterativa píxel a píxel — el rang
|
||||||
|
// típic és ~48 px, així que el cost és menyspreable.
|
||||||
|
void computeDamageRect(Direction dir, float spawn_x, float spawn_y, float range_px);
|
||||||
|
|
||||||
|
// Aplica dany aquest frame: itera Hazards (Mummy whippable) i
|
||||||
|
// jugadors (excepte `owner_`) que solapen amb `damage_rect_` i
|
||||||
|
// estan en estat "atacable", crida `enterHurt` amb la direcció
|
||||||
|
// adequada i els marca a `already_hit_` per garantir un sol
|
||||||
|
// impacte per swing per objectiu.
|
||||||
|
void applyDamage();
|
||||||
|
|
||||||
Direction direction_{Direction::EAST};
|
Direction direction_{Direction::EAST};
|
||||||
AnimationComponent anim_swing_;
|
AnimationComponent anim_swing_;
|
||||||
std::vector<int> active_frames_;
|
std::vector<int> active_frames_;
|
||||||
std::set<Entity*> already_hit_;
|
// Set d'objectius ja colpejats aquest swing: usem `void*` perquè
|
||||||
|
// hi guardem tant `Entity*` (Mummy) com `Player*` (que NO hereta
|
||||||
|
// d'Entity) sense `reinterpret_cast` semànticament dubtós.
|
||||||
|
std::unordered_set<void*> already_hit_;
|
||||||
float range_px_{0.0F};
|
float range_px_{0.0F};
|
||||||
|
SDL_FRect damage_rect_{0.0F, 0.0F, 0.0F, 0.0F};
|
||||||
|
|
||||||
|
EntityManager* entity_manager_{nullptr};
|
||||||
|
const std::vector<Player*>* players_{nullptr};
|
||||||
|
|
||||||
DirSprite sprite_north_;
|
DirSprite sprite_north_;
|
||||||
DirSprite sprite_south_;
|
DirSprite sprite_south_;
|
||||||
|
|||||||
@@ -441,12 +441,16 @@ class Player : public AnimatedSprite {
|
|||||||
// Cardinal complet (N/S/E/W) usat per a la detecció de girs de 180°.
|
// Cardinal complet (N/S/E/W) usat per a la detecció de girs de 180°.
|
||||||
// El parell (Facing, flip) només té 3+1 estats; aquesta enum n'afegeix
|
// El parell (Facing, flip) només té 3+1 estats; aquesta enum n'afegeix
|
||||||
// la 4a sense reorganitzar el sistema d'animacions.
|
// la 4a sense reorganitzar el sistema d'animacions.
|
||||||
|
// Pública perquè el `WhipAttack` la passa a `enterHurt` quan colpeja
|
||||||
|
// un altre jugador (friendly fire).
|
||||||
|
public:
|
||||||
enum class Cardinal : std::uint8_t { NONE,
|
enum class Cardinal : std::uint8_t { NONE,
|
||||||
NORTH,
|
NORTH,
|
||||||
SOUTH,
|
SOUTH,
|
||||||
EAST,
|
EAST,
|
||||||
WEST };
|
WEST };
|
||||||
|
|
||||||
|
private:
|
||||||
// Configuració d'inèrcia llegida del bloc `inertia:` del YAML. Amb
|
// Configuració d'inèrcia llegida del bloc `inertia:` del YAML. Amb
|
||||||
// `brake_time_ms == 0` (o bloc absent) la inèrcia està desactivada i
|
// `brake_time_ms == 0` (o bloc absent) la inèrcia està desactivada i
|
||||||
// el comportament és instantani: soltar input para el jugador en sec.
|
// el comportament és instantani: soltar input para el jugador en sec.
|
||||||
@@ -554,8 +558,12 @@ class Player : public AnimatedSprite {
|
|||||||
// i tria el que dóna més recorregut lliure.
|
// i tria el que dóna més recorregut lliure.
|
||||||
// Decelera linealment fins a parar i, en eixe instant, torna a
|
// Decelera linealment fins a parar i, en eixe instant, torna a
|
||||||
// GROUNDED. Sense col·lisió amb altres jugadors mentre dura.
|
// GROUNDED. Sense col·lisió amb altres jugadors mentre dura.
|
||||||
|
// Pública: la dispara tant la cinemàtica de "caure damunt" com el
|
||||||
|
// `WhipAttack` quan colpeja un altre jugador (friendly fire).
|
||||||
|
public:
|
||||||
void enterHurt(Cardinal preferred = Cardinal::NONE);
|
void enterHurt(Cardinal preferred = Cardinal::NONE);
|
||||||
|
|
||||||
|
private:
|
||||||
// Tria la direcció definitiva del HURT. Si `preferred` cabe el
|
// Tria la direcció definitiva del HURT. Si `preferred` cabe el
|
||||||
// recorregut natural complet, la retorna; d'altra manera tria la
|
// recorregut natural complet, la retorna; d'altra manera tria la
|
||||||
// direcció amb més recorregut lliure entre els 4 cardinals.
|
// direcció amb més recorregut lliure entre els 4 cardinals.
|
||||||
|
|||||||
@@ -908,6 +908,9 @@ void GameScene::spawnWhip(const WhipSpawnPayload& payload) {
|
|||||||
payload.spawn_y,
|
payload.spawn_y,
|
||||||
payload.range_px);
|
payload.range_px);
|
||||||
whip->setSurface(Resource::Cache::get().getSurface(whip->getSurfaceKey()));
|
whip->setSurface(Resource::Cache::get().getSurface(whip->getSurfaceKey()));
|
||||||
|
// Donem accés a Hazards i jugadors per a la fase de dany. Sense això
|
||||||
|
// el whip queda merament visual.
|
||||||
|
whip->setWorldContext(&entity_manager_, &players_view_);
|
||||||
entity_manager_.add(std::move(whip));
|
entity_manager_.add(std::move(whip));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user