forked from 0ad/0ad
Let the template define the actor used for the projectile. Also let projectiles have an impact animation (such as an explosion).
This will enable us in the future to have technologies that change projectiles. This is also somewhat of a refactoring. Patch By: Mate-86 Reviewed By: wraitii Trac Tickets: #1909 Differential Revision: https://code.wildfiregames.com/D945 This was SVN commit r20676.
This commit is contained in:
parent
7d7a4a6b14
commit
95c03dcc64
@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<actor version="1">
|
||||
<castshadow/>
|
||||
<group>
|
||||
<variant frequency="1" name="rock">
|
||||
<mesh>props/onager_projectile.dae</mesh>
|
||||
<props>
|
||||
<prop actor="particle/flame_large.xml" attachpoint="root"/>
|
||||
</props>
|
||||
<textures>
|
||||
<texture file="gaia/stone_temperate_granite.dds" name="baseTex"/>
|
||||
</textures>
|
||||
</variant>
|
||||
</group>
|
||||
</actor>
|
@ -8,7 +8,9 @@
|
||||
<prop actor="particle/smoke_catapult.xml" attachpoint="root"/>
|
||||
<prop actor="particle/flame_catapult.xml" attachpoint="root"/>
|
||||
</props>
|
||||
<textures><texture file="gaia/stone_temperate_granite.dds" name="baseTex"/></textures>
|
||||
<textures>
|
||||
<texture file="gaia/stone_temperate_granite.dds" name="baseTex"/>
|
||||
</textures>
|
||||
</variant>
|
||||
</group>
|
||||
</actor>
|
||||
|
@ -81,6 +81,11 @@ Attack.prototype.Schema =
|
||||
"<Multiplier>2</Multiplier>" +
|
||||
"</Bonus1>" +
|
||||
"</Bonuses>" +
|
||||
"<Projectile>" +
|
||||
"<ActorName>props/units/weapons/rock_flaming.xml</ActorName>" +
|
||||
"<ImpactActorName>props/units/weapons/rock_explosion.xml</ImpactActorName>" +
|
||||
"<ImpactAnimationLifetime>0.1</ImpactAnimationLifetime>" +
|
||||
"</Projectile>" +
|
||||
"<RestrictedClasses datatype=\"tokens\">Champion</RestrictedClasses>" +
|
||||
"<Splash>" +
|
||||
"<Shape>Circular</Shape>" +
|
||||
@ -150,6 +155,27 @@ Attack.prototype.Schema =
|
||||
Attack.prototype.bonusesSchema +
|
||||
Attack.prototype.preferredClassesSchema +
|
||||
Attack.prototype.restrictedClassesSchema +
|
||||
"<optional>" +
|
||||
"<element name='Projectile'>" +
|
||||
"<interleave>" +
|
||||
"<oneOrMore>" +
|
||||
"<choice>" +
|
||||
"<element name='ActorName' a:help='actor of the projectile animation'>" +
|
||||
"<text/>" +
|
||||
"</element>" +
|
||||
"<interleave>" +
|
||||
"<element name='ImpactActorName' a:help='actor of the projectile impact animation'>" +
|
||||
"<text/>" +
|
||||
"</element>" +
|
||||
"<element name='ImpactAnimationLifetime' a:help='length of the projectile impact animation'>" +
|
||||
"<ref name='positiveDecimal'/>" +
|
||||
"</element>" +
|
||||
"</interleave>" +
|
||||
"</choice>" +
|
||||
"</oneOrMore>" +
|
||||
"</interleave>" +
|
||||
"</element>" +
|
||||
"</optional>" +
|
||||
"<optional>" +
|
||||
"<element name='Splash'>" +
|
||||
"<interleave>" +
|
||||
@ -503,7 +529,35 @@ Attack.prototype.PerformAttack = function(type, target)
|
||||
|
||||
// Launch the graphical projectile.
|
||||
let cmpProjectileManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ProjectileManager);
|
||||
let id = cmpProjectileManager.LaunchProjectileAtPoint(this.entity, realTargetPosition, horizSpeed, gravity);
|
||||
|
||||
let actorName = "";
|
||||
let impactActorName = "";
|
||||
let impactAnimationLifetime = 0;
|
||||
if (this.template.Ranged.Projectile)
|
||||
{
|
||||
actorName = this.template.Ranged.Projectile.ActorName || "";
|
||||
impactActorName = this.template.Ranged.Projectile.ImpactActorName || "";
|
||||
impactAnimationLifetime = this.template.Ranged.Projectile.ImpactAnimationLifetime || 0;
|
||||
}
|
||||
|
||||
let launchPoint = selfPosition.clone();
|
||||
// TODO: remove this when all the ranged unit templates are updated with Projectile/Launchpoint
|
||||
launchPoint.y += 3;
|
||||
|
||||
let cmpVisual = Engine.QueryInterface(this.entity, IID_Visual);
|
||||
if (cmpVisual)
|
||||
{
|
||||
// if the projectile definition is missing from the template
|
||||
// then fallback to the projectile name and launchpoint in the visual actor
|
||||
if (!actorName)
|
||||
actorName = cmpVisual.GetProjectileActor();
|
||||
|
||||
let visualActorLaunchPoint = cmpVisual.GetProjectileLaunchPoint();
|
||||
if (visualActorLaunchPoint.length() > 0)
|
||||
launchPoint = visualActorLaunchPoint;
|
||||
}
|
||||
|
||||
let id = cmpProjectileManager.LaunchProjectileAtPoint(launchPoint, realTargetPosition, horizSpeed, gravity, actorName, impactActorName, impactAnimationLifetime);
|
||||
|
||||
let attackImpactSound = "";
|
||||
let cmpSound = Engine.QueryInterface(this.entity, IID_Sound);
|
||||
|
@ -13,6 +13,10 @@
|
||||
<RepeatTime>5000</RepeatTime>
|
||||
<Spread>4.0</Spread>
|
||||
<Delay>0</Delay>
|
||||
<Projectile>
|
||||
<ImpactActorName>props/units/weapons/rock_explosion.xml</ImpactActorName>
|
||||
<ImpactAnimationLifetime>0.1</ImpactAnimationLifetime>
|
||||
</Projectile>
|
||||
<Splash>
|
||||
<Shape>Circular</Shape>
|
||||
<Range>10</Range>
|
||||
|
@ -25,7 +25,6 @@
|
||||
#include "ICmpPosition.h"
|
||||
#include "ICmpRangeManager.h"
|
||||
#include "ICmpTerrain.h"
|
||||
#include "ICmpVisual.h"
|
||||
#include "simulation2/MessageTypes.h"
|
||||
|
||||
#include "graphics/Frustum.h"
|
||||
@ -107,13 +106,16 @@ public:
|
||||
}
|
||||
}
|
||||
|
||||
virtual uint32_t LaunchProjectileAtPoint(entity_id_t source, const CFixedVector3D& target, fixed speed, fixed gravity)
|
||||
virtual uint32_t LaunchProjectileAtPoint(const CFixedVector3D& launchPoint, const CFixedVector3D& target, fixed speed, fixed gravity, const std::wstring& actorName, const std::wstring& impactActorName, fixed impactAnimationLifetime)
|
||||
{
|
||||
return LaunchProjectile(source, target, speed, gravity);
|
||||
return LaunchProjectile(launchPoint, target, speed, gravity, actorName, impactActorName, impactAnimationLifetime);
|
||||
}
|
||||
|
||||
virtual void RemoveProjectile(uint32_t);
|
||||
|
||||
void RenderModel(CModelAbstract& model, const CVector3D& position, SceneCollector& collector, const CFrustum& frustum, bool culling,
|
||||
ICmpRangeManager::CLosQuerier los, bool losRevealAll) const;
|
||||
|
||||
private:
|
||||
struct Projectile
|
||||
{
|
||||
@ -124,8 +126,11 @@ private:
|
||||
float time;
|
||||
float timeHit;
|
||||
float gravity;
|
||||
bool stopped;
|
||||
float impactAnimationLifetime;
|
||||
uint32_t id;
|
||||
std::wstring impactActorName;
|
||||
bool isImpactAnimationCreated;
|
||||
bool stopped;
|
||||
|
||||
CVector3D position(float t)
|
||||
{
|
||||
@ -141,13 +146,23 @@ private:
|
||||
}
|
||||
};
|
||||
|
||||
struct ProjectileImpactAnimation
|
||||
{
|
||||
CUnit* unit;
|
||||
CVector3D pos;
|
||||
float time;
|
||||
};
|
||||
|
||||
std::vector<Projectile> m_Projectiles;
|
||||
|
||||
std::vector<ProjectileImpactAnimation> m_ProjectileImpactAnimations;
|
||||
|
||||
uint32_t m_ActorSeed;
|
||||
|
||||
uint32_t m_NextId;
|
||||
|
||||
uint32_t LaunchProjectile(entity_id_t source, CFixedVector3D targetPoint, fixed speed, fixed gravity);
|
||||
uint32_t LaunchProjectile(CFixedVector3D launchPoint, CFixedVector3D targetPoint, fixed speed, fixed gravity,
|
||||
const std::wstring& actorName, const std::wstring& impactActorName, fixed impactAnimationLifetime);
|
||||
|
||||
void AdvanceProjectile(Projectile& projectile, float dt) const;
|
||||
|
||||
@ -158,46 +173,36 @@ private:
|
||||
|
||||
REGISTER_COMPONENT_TYPE(ProjectileManager)
|
||||
|
||||
uint32_t CCmpProjectileManager::LaunchProjectile(entity_id_t source, CFixedVector3D targetPoint, fixed speed, fixed gravity)
|
||||
uint32_t CCmpProjectileManager::LaunchProjectile(CFixedVector3D launchPoint, CFixedVector3D targetPoint, fixed speed, fixed gravity, const std::wstring& actorName, const std::wstring& impactActorName, fixed impactAnimationLifetime)
|
||||
{
|
||||
// This is network synced so don't use GUI checks before incrementing or it breaks any non GUI simulations
|
||||
uint32_t currentId = m_NextId++;
|
||||
|
||||
if (!GetSimContext().HasUnitManager())
|
||||
if (!GetSimContext().HasUnitManager() || actorName.empty())
|
||||
return currentId; // do nothing if graphics are disabled
|
||||
|
||||
CmpPtr<ICmpVisual> cmpSourceVisual(GetSimContext(), source);
|
||||
if (!cmpSourceVisual)
|
||||
return currentId;
|
||||
|
||||
std::wstring name = cmpSourceVisual->GetProjectileActor();
|
||||
if (name.empty())
|
||||
{
|
||||
// If the actor was actually loaded, complain that it doesn't have a projectile
|
||||
if (!cmpSourceVisual->GetActorShortName().empty())
|
||||
LOGERROR("Unit with actor '%s' launched a projectile but has no actor on 'projectile' attachpoint", utf8_from_wstring(cmpSourceVisual->GetActorShortName()));
|
||||
return currentId;
|
||||
}
|
||||
|
||||
Projectile projectile;
|
||||
projectile.id = currentId;
|
||||
projectile.time = 0.f;
|
||||
projectile.stopped = false;
|
||||
projectile.gravity = gravity.ToFloat();
|
||||
projectile.isImpactAnimationCreated = false;
|
||||
|
||||
projectile.origin = cmpSourceVisual->GetProjectileLaunchPoint();
|
||||
if (!projectile.origin)
|
||||
if (!impactActorName.empty())
|
||||
{
|
||||
// If there's no explicit launch point, take a guess based on the entity position
|
||||
CmpPtr<ICmpPosition> sourcePos(GetSimContext(), source);
|
||||
if (!sourcePos)
|
||||
return currentId;
|
||||
projectile.origin = sourcePos->GetPosition();
|
||||
projectile.origin.Y += 3.f;
|
||||
projectile.impactActorName = impactActorName;
|
||||
projectile.impactAnimationLifetime = impactAnimationLifetime.ToFloat();
|
||||
}
|
||||
else
|
||||
{
|
||||
projectile.impactActorName = L"";
|
||||
projectile.impactAnimationLifetime = 0.0f;
|
||||
}
|
||||
|
||||
projectile.origin = launchPoint;
|
||||
|
||||
std::set<CStr> selections;
|
||||
projectile.unit = GetSimContext().GetUnitManager().CreateUnit(name, m_ActorSeed++, selections);
|
||||
projectile.unit = GetSimContext().GetUnitManager().CreateUnit(actorName, m_ActorSeed++, selections);
|
||||
if (!projectile.unit) // The error will have already been logged
|
||||
return currentId;
|
||||
|
||||
@ -282,22 +287,58 @@ void CCmpProjectileManager::Interpolate(float frameTime)
|
||||
// Remove the ones that have reached their target
|
||||
for (size_t i = 0; i < m_Projectiles.size(); )
|
||||
{
|
||||
// Projectiles hitting targets get removed immediately.
|
||||
// Those hitting the ground stay for a while, because it looks pretty.
|
||||
if (m_Projectiles[i].stopped)
|
||||
if (!m_Projectiles[i].stopped)
|
||||
{
|
||||
if (m_Projectiles[i].time - m_Projectiles[i].timeHit > PROJECTILE_DECAY_TIME)
|
||||
{
|
||||
// Delete in-place by swapping with the last in the list
|
||||
std::swap(m_Projectiles[i], m_Projectiles.back());
|
||||
GetSimContext().GetUnitManager().DeleteUnit(m_Projectiles.back().unit);
|
||||
m_Projectiles.pop_back();
|
||||
continue; // don't increment i
|
||||
}
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!m_Projectiles[i].impactActorName.empty() && !m_Projectiles[i].isImpactAnimationCreated)
|
||||
{
|
||||
m_Projectiles[i].isImpactAnimationCreated = true;
|
||||
CMatrix3D transform;
|
||||
CQuaternion quat;
|
||||
quat.ToMatrix(transform);
|
||||
transform.Translate(m_Projectiles[i].pos);
|
||||
|
||||
std::set<CStr> selections;
|
||||
CUnit* unit = GetSimContext().GetUnitManager().CreateUnit(m_Projectiles[i].impactActorName, m_ActorSeed++, selections);
|
||||
unit->GetModel().SetTransform(transform);
|
||||
|
||||
ProjectileImpactAnimation projectileImpactAnimation;
|
||||
projectileImpactAnimation.unit = unit;
|
||||
projectileImpactAnimation.time = m_Projectiles[i].impactAnimationLifetime;
|
||||
projectileImpactAnimation.pos = m_Projectiles[i].pos;
|
||||
m_ProjectileImpactAnimations.push_back(projectileImpactAnimation);
|
||||
}
|
||||
|
||||
// Projectiles hitting targets get removed immediately.
|
||||
// Those hitting the ground stay for a while, because it looks pretty.
|
||||
if (m_Projectiles[i].time - m_Projectiles[i].timeHit > PROJECTILE_DECAY_TIME)
|
||||
{
|
||||
// Delete in-place by swapping with the last in the list
|
||||
std::swap(m_Projectiles[i], m_Projectiles.back());
|
||||
GetSimContext().GetUnitManager().DeleteUnit(m_Projectiles.back().unit);
|
||||
m_Projectiles.pop_back();
|
||||
continue;
|
||||
}
|
||||
++i;
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < m_ProjectileImpactAnimations.size();)
|
||||
{
|
||||
if (m_ProjectileImpactAnimations[i].time > 0)
|
||||
{
|
||||
m_ProjectileImpactAnimations[i].time -= frameTime;
|
||||
++i;
|
||||
}
|
||||
else
|
||||
{
|
||||
std::swap(m_ProjectileImpactAnimations[i], m_ProjectileImpactAnimations.back());
|
||||
GetSimContext().GetUnitManager().DeleteUnit(m_ProjectileImpactAnimations.back().unit);
|
||||
m_ProjectileImpactAnimations.pop_back();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void CCmpProjectileManager::RemoveProjectile(uint32_t id)
|
||||
@ -316,6 +357,25 @@ void CCmpProjectileManager::RemoveProjectile(uint32_t id)
|
||||
}
|
||||
}
|
||||
|
||||
void CCmpProjectileManager::RenderModel(CModelAbstract& model, const CVector3D& position, SceneCollector& collector,
|
||||
const CFrustum& frustum, bool culling, ICmpRangeManager::CLosQuerier los, bool losRevealAll) const
|
||||
{
|
||||
// Don't display objects outside the visible area
|
||||
ssize_t posi = (ssize_t)(0.5f + position.X / TERRAIN_TILE_SIZE);
|
||||
ssize_t posj = (ssize_t)(0.5f + position.Z / TERRAIN_TILE_SIZE);
|
||||
if (!losRevealAll && !los.IsVisible(posi, posj))
|
||||
return;
|
||||
|
||||
model.ValidatePosition();
|
||||
|
||||
if (culling && !frustum.IsBoxVisible(model.GetWorldBoundsRec()))
|
||||
return;
|
||||
|
||||
// TODO: do something about LOS (copy from CCmpVisualActor)
|
||||
|
||||
collector.SubmitRecursive(&model);
|
||||
}
|
||||
|
||||
void CCmpProjectileManager::RenderSubmit(SceneCollector& collector, const CFrustum& frustum, bool culling) const
|
||||
{
|
||||
CmpPtr<ICmpRangeManager> cmpRangeManager(GetSystemEntity());
|
||||
@ -323,23 +383,14 @@ void CCmpProjectileManager::RenderSubmit(SceneCollector& collector, const CFrust
|
||||
ICmpRangeManager::CLosQuerier los(cmpRangeManager->GetLosQuerier(player));
|
||||
bool losRevealAll = cmpRangeManager->GetLosRevealAll(player);
|
||||
|
||||
for (size_t i = 0; i < m_Projectiles.size(); ++i)
|
||||
for (const Projectile& projectile : m_Projectiles)
|
||||
{
|
||||
// Don't display projectiles outside the visible area
|
||||
ssize_t posi = (ssize_t)(0.5f + m_Projectiles[i].pos.X / TERRAIN_TILE_SIZE);
|
||||
ssize_t posj = (ssize_t)(0.5f + m_Projectiles[i].pos.Z / TERRAIN_TILE_SIZE);
|
||||
if (!losRevealAll && !los.IsVisible(posi, posj))
|
||||
continue;
|
||||
RenderModel(projectile.unit->GetModel(), projectile.pos, collector, frustum, culling, los, losRevealAll);
|
||||
}
|
||||
|
||||
CModelAbstract& model = m_Projectiles[i].unit->GetModel();
|
||||
|
||||
model.ValidatePosition();
|
||||
|
||||
if (culling && !frustum.IsBoxVisible(model.GetWorldBoundsRec()))
|
||||
continue;
|
||||
|
||||
// TODO: do something about LOS (copy from CCmpVisualActor)
|
||||
|
||||
collector.SubmitRecursive(&model);
|
||||
for (const ProjectileImpactAnimation& projectileImpactAnimation : m_ProjectileImpactAnimations)
|
||||
{
|
||||
RenderModel(projectileImpactAnimation.unit->GetModel(), projectileImpactAnimation.pos,
|
||||
collector, frustum, culling, los, losRevealAll);
|
||||
}
|
||||
}
|
||||
|
@ -387,10 +387,10 @@ public:
|
||||
return m_Unit->GetObject().m_ProjectileModelName;
|
||||
}
|
||||
|
||||
virtual CVector3D GetProjectileLaunchPoint() const
|
||||
virtual CFixedVector3D GetProjectileLaunchPoint() const
|
||||
{
|
||||
if (!m_Unit)
|
||||
return CVector3D();
|
||||
return CFixedVector3D();
|
||||
|
||||
if (m_Unit->GetModel().ToCModel())
|
||||
{
|
||||
@ -407,10 +407,13 @@ public:
|
||||
|
||||
CModelAbstract* ammo = m_Unit->GetModel().ToCModel()->FindFirstAmmoProp();
|
||||
if (ammo)
|
||||
return ammo->GetTransform().GetTranslation();
|
||||
{
|
||||
CVector3D vector = ammo->GetTransform().GetTranslation();
|
||||
return CFixedVector3D(fixed::FromFloat(vector.X), fixed::FromFloat(vector.Y), fixed::FromFloat(vector.Z));
|
||||
}
|
||||
}
|
||||
|
||||
return CVector3D();
|
||||
return CFixedVector3D();
|
||||
}
|
||||
|
||||
virtual void SetVariant(const CStr& key, const CStr& selection)
|
||||
|
@ -22,6 +22,6 @@
|
||||
#include "simulation2/system/InterfaceScripted.h"
|
||||
|
||||
BEGIN_INTERFACE_WRAPPER(ProjectileManager)
|
||||
DEFINE_INTERFACE_METHOD_4("LaunchProjectileAtPoint", uint32_t, ICmpProjectileManager, LaunchProjectileAtPoint, entity_id_t, CFixedVector3D, fixed, fixed)
|
||||
DEFINE_INTERFACE_METHOD_7("LaunchProjectileAtPoint", uint32_t, ICmpProjectileManager, LaunchProjectileAtPoint, CFixedVector3D, CFixedVector3D, fixed, fixed, std::wstring, std::wstring, fixed)
|
||||
DEFINE_INTERFACE_METHOD_1("RemoveProjectile", void, ICmpProjectileManager, RemoveProjectile, uint32_t)
|
||||
END_INTERFACE_WRAPPER(ProjectileManager)
|
||||
|
@ -38,9 +38,12 @@ public:
|
||||
* @param target target point
|
||||
* @param speed horizontal speed in m/s
|
||||
* @param gravity gravitational acceleration in m/s^2 (determines the height of the ballistic curve)
|
||||
* @param actorName name of the flying projectile actor
|
||||
* @param impactActorName name of the animation actor played when the projectile hits the target or the ground
|
||||
* @param impactAnimationLifetime animation lenth
|
||||
* @return id of the created projectile
|
||||
*/
|
||||
virtual uint32_t LaunchProjectileAtPoint(entity_id_t source, const CFixedVector3D& target, fixed speed, fixed gravity) = 0;
|
||||
virtual uint32_t LaunchProjectileAtPoint(const CFixedVector3D& launchPoint, const CFixedVector3D& target, fixed speed, fixed gravity, const std::wstring& actorName, const std::wstring& impactActorName, fixed impactAnimationLifetime) = 0;
|
||||
|
||||
/**
|
||||
* Removes a projectile, used when the projectile has hit a target
|
||||
|
@ -24,6 +24,8 @@
|
||||
BEGIN_INTERFACE_WRAPPER(Visual)
|
||||
DEFINE_INTERFACE_METHOD_2("SetVariant", void, ICmpVisual, SetVariant, CStr, CStr)
|
||||
DEFINE_INTERFACE_METHOD_CONST_0("GetAnimationName", std::string, ICmpVisual, GetAnimationName)
|
||||
DEFINE_INTERFACE_METHOD_CONST_0("GetProjectileActor", std::wstring, ICmpVisual, GetProjectileActor)
|
||||
DEFINE_INTERFACE_METHOD_CONST_0("GetProjectileLaunchPoint", CFixedVector3D, ICmpVisual, GetProjectileLaunchPoint)
|
||||
DEFINE_INTERFACE_METHOD_4("SelectAnimation", void, ICmpVisual, SelectAnimation, std::string, bool, fixed, std::wstring)
|
||||
DEFINE_INTERFACE_METHOD_1("SelectMovementAnimation", void, ICmpVisual, SelectMovementAnimation, fixed)
|
||||
DEFINE_INTERFACE_METHOD_1("ResetMoveAnimation", void, ICmpVisual, ResetMoveAnimation, std::string)
|
||||
|
@ -24,6 +24,7 @@
|
||||
#include "maths/BoundingBoxOriented.h"
|
||||
#include "maths/BoundingBoxAligned.h"
|
||||
#include "maths/Fixed.h"
|
||||
#include "maths/FixedVector3D.h"
|
||||
#include "lib/file/vfs/vfs_path.h"
|
||||
|
||||
class CUnit;
|
||||
@ -68,9 +69,10 @@ public:
|
||||
/**
|
||||
* Return the exact position where a projectile should be launched from (based on the actor's
|
||||
* ammo prop points).
|
||||
* Return type is CFixedVector3D because it is exposed to the JS interface.
|
||||
* Returns (0,0,0) if no point can be found.
|
||||
*/
|
||||
virtual CVector3D GetProjectileLaunchPoint() const = 0;
|
||||
virtual CFixedVector3D GetProjectileLaunchPoint() const = 0;
|
||||
|
||||
/**
|
||||
* Returns the underlying unit of this visual actor. May return NULL to indicate that no unit exists (e.g. may happen if the
|
||||
|
Loading…
Reference in New Issue
Block a user