I’m finally able to start working on building out a combat system for this game, and boy is it a lot more complicated than I originally anticipated. Before that however, I had to modify my architecture a bit because during building out the ECS system for combat I realized I didn’t actually understand how Jolt dispatches contact hits.
Jolt’s ContactListener, BYODispatcher
Preconceived notions of how I think things work are probably my biggest fault when it comes to being a developer. And me misunderstanding the ContactListener was no exception. For some reason I thought by simply declaring a class that inherits from JPH::ContactListener and calling PhysicsSystem->SetContactListener(...) it would register itself with the Collision detection system. Turns out it’s a bit more complicated.
My original idea was this:
- Entity with the Character component creates and sets an
AttackDetailscomponent. - Combat system picks up the new component and creates the physics body. This
AttackDetailscomponent includes a field member that inherits fromJPH::ContactListenerand overrides the virtual function’sOnContactAddedmethod and then calls PhysicsSystem->SetContactListener(Attack.Hit); - Collision occurs, Jolt calls
ContactListener(s)until it finds this newly createdAttackDetailshit component.
There’s a few things wrong with this, the major one being if you call SetContactListener, it simply overwrites the previous listener! You have to Bring Your Own Dispatcher.
So, that is what I ended up doing, here’s my implementation of the dispatcher:
class ContactDispatcher : public JPH::ContactListener
{
public:
ContactDispatcher()
{
Listeners.reserve(100);
}
// See: ContactListener
virtual JPH::ValidateResult OnContactValidate(const JPH::Body &InBody1, const JPH::Body &InBody2, JPH::RVec3Arg InBaseOffset, const JPH::CollideShapeResult &InCollisionResult) override;
virtual void OnContactAdded(const JPH::Body &InBody1, const JPH::Body &InBody2, const JPH::ContactManifold &InManifold, JPH::ContactSettings &IOSettings) override;
virtual void OnContactPersisted(const JPH::Body &InBody1, const JPH::Body &InBody2, const JPH::ContactManifold &InManifold, JPH::ContactSettings &IOSettings) override;
virtual void OnContactRemoved(const JPH::SubShapeIDPair &InSubShapePair) override;
/**
* @brief Adds a listener, not thread safe
*
* @param Listener
*/
void AddListener(JPH::ContactListener *Listener)
{
Listeners.emplace_back(Listener);
}
/**
* @brief Removes a listener, not thread safe
*
* @param Listener
*/
void RemoveListener(JPH::ContactListener *Listener)
{
std::erase(Listeners, Listener);
}
virtual ~ContactDispatcher() = default;
private:
std::vector<JPH::ContactListener *> Listeners;
};
Pretty standard, just make a vector of listeners to add, then in OnContactAdded iterate over them:
void ContactDispatcher::OnContactAdded(const JPH::Body &InBody1, const JPH::Body &InBody2, const JPH::ContactManifold &InManifold, JPH::ContactSettings &IOSettings)
{
for (auto Listener : Listeners)
{
Listener->OnContactAdded(InBody1, InBody2, InManifold, IOSettings);
}
}
This could obviously be enhanced to filter on object layers that the listener cared about, but for now each listener will do that internally. As of now I have two listeners:
- Level::Level – This class/flecs module handles our Area of Interest sensors and collisions.
- Combat::Combat – This class/flecs module will handle attack related collisions.
Bringing the Entities back into ECS
The other major design flaw was that I was going to model the attacks as components. These components would have been added to the character entity. However, my combat system will allow characters to have multiple outgoing attacks. What this means is I have the problem of requiring this attack component to have a mutable vector to track all of these outgoing attacks. This is no good, and sort of defeats the purpose of an ECS.
It took me about a day or two of noodling, and actually looking over a GW2 combat simulator (based off of EnTT instead of Flecs) before it finally clicked. Attacks should be created as entities. These entities will then have a reference to the Instigator’s (attacker) entity ID!
This is great because now I can have multiple concurrent attacks going out with their own collision sensors but still reference who created them. It also makes logical sense they should be entities as as they have their own lifecycle (durations).
I was now ready to build a basic combat system for Melee attacks.
How to build a melee attack?
In a single player game this is some what straight forward. In Unreal it’s pretty common to have a sword, then create a socket on the sword, then add a sphere trace to it. As the combat animation plays, the character swings the sword and the sphere trace follows it. Anything it hits, triggers on the OnHit callback and you do your calculations.
In a headless MMORPG server, this is… a bit different. I don’t want to load animations and setup sockets and traces. It is just too resource intensive and to be honest, Complicated.
My plan instead is to take the instigator, calculate the rough distance the weapon can reach and the duration of the animation. The combat system will spawn a sphere body from Jolt with collision enabled on it with it’s own layer. Physics systems use layers to filter out collisions as you can see here:
// BroadPhaseLayerInterface implementation
// This defines a mapping between object and broadphase layers.
class BPLayerInterfaceImpl final : public BroadPhaseLayerInterface
{
public:
BPLayerInterfaceImpl()
{
// Create a mapping table from object to broad phase layer
ObjectToBroadPhase[Layers::NON_MOVING] = BroadPhaseLayers::NON_MOVING;
ObjectToBroadPhase[Layers::MOVING] = BroadPhaseLayers::MOVING;
ObjectToBroadPhase[Layers::SENSOR] = BroadPhaseLayers::SENSOR;
ObjectToBroadPhase[Layers::CHARACTER] = BroadPhaseLayers::CHARACTER;
ObjectToBroadPhase[Layers::COMBAT] = BroadPhaseLayers::COMBAT;
}
// ...
}
// Class that determines if two object layers can collide
class ObjectLayerPairFilterImpl : public ObjectLayerPairFilter
{
public:
virtual bool ShouldCollide(ObjectLayer InObject1, ObjectLayer InObject2) const override
{
switch (InObject1)
{
case Layers::NON_MOVING:
return InObject2 == Layers::MOVING; // Non moving only collides with moving
case Layers::MOVING:
return InObject2 == Layers::NON_MOVING || InObject2 == Layers::MOVING || InObject2 == Layers::SENSOR;; // Moving collides with everything
case Layers::CHARACTER:
// TODO: We may only want characters to collide when PvP'ing/fighting which will greatly complicate this class
return InObject2 == Layers::CHARACTER || InObject2 == Layers::NON_MOVING || InObject2 == Layers::SENSOR || InObject2 == Layers::COMBAT;
default:
JPH_ASSERT(false);
return false;
}
}
};
With this body created in the physic’s system we can then update it’s position every tick to make sure it’s stuck in front of the character that created the attack if they are moving around. Here’s the full ECS system:
GameWorld.system<AttackDetails>("PlayerAttack")
.kind(flecs::PreUpdate)
.each([this](flecs::iter& It, size_t Index, AttackDetails &Attack)
{
if (!Attack.Instigator.is_valid() || !Attack.Instigator.is_alive())
{
Logger->Warn("Attack instigator is no longer valid, attack failed");
return;
}
auto Attacker = Attack.Instigator.get<character::PMOCharacter>();
auto UpdatedPosition = convert::PositionToJPHVec3(Attacker->GetPosition());
if (Attack.CumulativeTime == 0.f)
{
auto UpdatedRotation = convert::QuatToJPHQuat(Attacker->GetRotation());
Logger->Info("Attacking with Base Dmg: {} Pos: {} {} {} Instigator: {}", Attack.BaseDamage, UpdatedPosition.GetX(), UpdatedPosition.GetY(), UpdatedPosition.GetZ(), Attack.Instigator.id());
Attack.AttackBodyID = InitializeAttack(Attack, UpdatedPosition, UpdatedRotation);
}
Attack.CumulativeTime += It.delta_time();
if (Attack.CumulativeTime >= Attack.RunTime)
{
Logger->Info("Removing Attack");
auto AttackEntity = It.entity(Index);
PhysicsSystem->System->GetBodyInterface().DestroyBody(Attack.AttackBodyID);
AttackEntity.destruct();
}
else
{
// Activate and wait for OnContactAdded / CombatSystem->OnHit
Logger->Info("Updating Pos: {} {} {}", UpdatedPosition.GetX(), UpdatedPosition.GetY(), UpdatedPosition.GetZ());
PhysicsSystem->System->GetBodyInterface().SetPosition(Attack.AttackBodyID, UpdatedPosition, EActivation::Activate);
}
});
This system also tracks the time and destroys the entity and removes the physics body from the physics system when the attack “completes”.
Since we have our Combat::OnContactAdded listener set, we then wait for a collision and check who it hits & apply damage:
void Combat::OnContactAdded(const JPH::Body &InBody1, const JPH::Body &InBody2, const JPH::ContactManifold &InManifold, JPH::ContactSettings &IOSettings)
{
// Validate this contact hit is from a sensor, otherwise bail out early
// ...
// Also make sure this is for Character and Combat layers
auto BodyIsCombat = InBody1.GetObjectLayer() == physics::Layers::COMBAT || InBody2.GetObjectLayer() == physics::Layers::COMBAT;
auto BodyIsCharacter = InBody1.GetObjectLayer() == physics::Layers::CHARACTER || InBody2.GetObjectLayer() == physics::Layers::CHARACTER;
if (!BodyIsCombat || !BodyIsCharacter)
{
Logger->Info("Combat::OnContactAdded returning since not char or combat");
return;
}
// Find who it hit
flecs::entity Target = OnHitTargetQuery.find([&](character::PMOCharacter& Char)
{
return Char.IsBody(InBody1.GetID()) || Char.IsBody(InBody2.GetID());
});
if (!Target.is_valid() || !Target.is_alive())
{
Logger->Debug("OnContactAdded Attack Target not found or invalid");
return;
}
// Get the attack details
flecs::entity Attack = OnHitAttackQuery.find([&](AttackDetails &Attack)
{
return (Attack.AttackBodyID == InBody1.GetID() || Attack.AttackBodyID == InBody2.GetID());
});
if (!Attack.is_valid() || !Attack.is_alive())
{
Logger->Debug("OnContactAdded Attack not found or invalid");
return;
}
auto Details = Attack.get<AttackDetails>();
if (!Details)
{
Logger->Debug("OnContactAdded Attack Entity did not have AttackDetails component");
return;
}
// Don't want to hit ourself!! (Well some attacks might which we can have a property for)
if (Target == Details->Instigator)
{
Logger->Debug("OnContactAdded Ignoring attacking self!");
return;
}
// BAM!
Logger->Info("Doing {} damage to {}!", Details->BaseDamage, Target.id());
}
To actually spawn attacks we will bind a key to it, then create an attack entity when pressed (and if the character is able to):
// in handle input...
// ... find player ...
// Create attack entity to be picked up by our combat system
auto AttackEntity = GameWorld.entity().set<combat::AttackDetails>({Player1, 2.f, 2.f, 30.f, 1, combat::AttackContactShape::Sphere});
The combat system will now pick up this new entity with the combat::AttackDetails component registered to it! Meaning Player1 in the above call can create many of these entities and they’ll all be separate instances!
So now the flow is:
- In character input handling, when an attack key is pressed, a new entity is spawned with the
AttackDetailscomponent referencing who spawned it. - The combat system on the next tick picks up the new component and creates the physics body and stores the body id for lookup.
- A collision occurs, Jolt calls my physic’s module custom
ContactDispatcherwhich iterates over a vector of listeners. - Finally the Combat::OnContactAdded listener is called to handle the hit.
- Extract the character that was hit along with the attack details using flecs queries.
- TODO: Actually apply damage.
And with that we have a very basic melee combat system. I’m going to spend a few more days getting this cleaned up and then start getting swing animations and other stuff in to UE5 to make it more visual. Until next time!
