feat: server side knockback for AI moved objects

The client only simulates knockbacks on the character it controls
(LWOControllablePhysComponent::msgKnockback); enemies and NPCs are drawn
where the server puts them, so a knockback on them did nothing and
scripts stunned them instead.

MovementAIComponent::Knockback now flies the object the way the client
flies its character: a vector longer than 5 lifts it 0.5, throws it with
that velocity under WorldConfig gravity (times its gravity scale) until
it lands on the navmesh, no sooner than 250ms later; a shorter one moves
it by the vector. Walls taller than a step stop sideways motion, the
landing is put back onto the navmesh, and pathing and the combat AI wait
until it lands (destinations set mid air are walked to afterwards).
Position and velocity go out in the normal serialization every tick.

KnockbackBehavior builds the vector like the client's Cast (strength
capped at 300, angle as elevation, relative, caster and ignore_self) and
knocks back server moved targets that aren't immune, both when the
server casts and when a client's skill hits. The blocked bit it writes
now also answers for the target's knockback immunity, so a player whose
client hasn't caught up with a Personal Fortress isn't knocked out of it.

The AM shield generators knock enemies back like live instead of
stunning them.

Fixes #257
Refs #185

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-26 19:11:41 -05:00
parent e36f894f1f
commit 2d76c81bba
13 changed files with 540 additions and 22 deletions

View File

@@ -208,6 +208,9 @@ void BaseCombatAIComponent::Update(const float deltaTime) {
return;
}
// Flying from a knockback: pick a destination again once we land
if (m_MovementAI->IsKnockedBack()) return;
if (m_Timer > 0.0f) {
m_Timer -= deltaTime;
return;

View File

@@ -427,15 +427,15 @@ public:
);
// Getters for status immunities
const bool GetImmuneToBasicAttack() { return m_ImmuneToBasicAttackCount > 0; };
const bool GetImmuneToDamageOverTime() { return m_ImmuneToDamageOverTimeCount > 0; };
const bool GetImmuneToKnockback() { return m_ImmuneToKnockbackCount > 0; };
const bool GetImmuneToInterrupt() { return m_ImmuneToInterruptCount > 0; };
const bool GetImmuneToSpeed() { return m_ImmuneToSpeedCount > 0; };
const bool GetImmuneToImaginationGain() { return m_ImmuneToImaginationGainCount > 0; };
const bool GetImmuneToImaginationLoss() { return m_ImmuneToImaginationLossCount > 0; };
const bool GetImmuneToQuickbuildInterrupt() { return m_ImmuneToQuickbuildInterruptCount > 0; };
const bool GetImmuneToPullToPoint() { return m_ImmuneToPullToPointCount > 0; };
bool GetImmuneToBasicAttack() const { return m_ImmuneToBasicAttackCount > 0; };
bool GetImmuneToDamageOverTime() const { return m_ImmuneToDamageOverTimeCount > 0; };
bool GetImmuneToKnockback() const { return m_ImmuneToKnockbackCount > 0; };
bool GetImmuneToInterrupt() const { return m_ImmuneToInterruptCount > 0; };
bool GetImmuneToSpeed() const { return m_ImmuneToSpeedCount > 0; };
bool GetImmuneToImaginationGain() const { return m_ImmuneToImaginationGainCount > 0; };
bool GetImmuneToImaginationLoss() const { return m_ImmuneToImaginationLossCount > 0; };
bool GetImmuneToQuickbuildInterrupt() const { return m_ImmuneToQuickbuildInterruptCount > 0; };
bool GetImmuneToPullToPoint() const { return m_ImmuneToPullToPointCount > 0; };
// Damage cooldown setters/getters
void SetDamageCooldownTimer(float value) { m_DamageCooldownTimer = value; }

View File

@@ -31,6 +31,13 @@ namespace {
* Cache of all lots and their respective speeds
*/
std::map<LOT, float> m_PhysicsSpeedCache;
// Half the height of a character: how far a short knockback may drop onto the ground (the client uses its
// character's height / 2)
constexpr float KNOCKBACK_MAX_DROP = 2.0f;
// The client's gravity when WorldConfig has none (0x015fd9a8)
constexpr float DEFAULT_GRAVITY = 9.8f;
}
MovementAIComponent::MovementAIComponent(Entity* parent, const int32_t componentID, MovementAIInfo info) : Component(parent, componentID) {
@@ -79,6 +86,11 @@ void MovementAIComponent::SetPath(const std::string pathName) {
void MovementAIComponent::Pause() {
if (m_Paused) return;
m_Paused = true;
// A stun doesn't stop a knockback mid air; the entity stays put once it lands
if (IsKnockedBack()) {
m_SavedVelocity = NiPoint3Constant::ZERO;
return;
}
SetPosition(ApproximateLocation());
m_SavedVelocity = GetVelocity();
SetVelocity(NiPoint3Constant::ZERO);
@@ -88,6 +100,7 @@ void MovementAIComponent::Pause() {
void MovementAIComponent::Resume() {
if (!m_Paused) return;
m_Paused = false;
if (IsKnockedBack()) return;
SetVelocity(m_SavedVelocity);
m_SavedVelocity = NiPoint3Constant::ZERO;
SetRotation(QuatUtils::LookAt(m_Parent->GetPosition(), m_NextWaypoint));
@@ -95,6 +108,11 @@ void MovementAIComponent::Resume() {
}
void MovementAIComponent::Update(const float deltaTime) {
if (IsKnockedBack()) {
UpdateKnockback(deltaTime);
return;
}
if (m_Paused) return;
auto* const quickBuildComponent = m_Parent->GetComponent<QuickBuildComponent>();
@@ -256,6 +274,8 @@ NiPoint3 MovementAIComponent::ApproximateLocation() const {
}
bool MovementAIComponent::Warp(const NiPoint3& point) {
m_Knockback.Cancel();
m_DestinationAfterKnockback.reset();
Stop();
NiPoint3 destination = point;
@@ -276,6 +296,7 @@ bool MovementAIComponent::Warp(const NiPoint3& point) {
}
void MovementAIComponent::Stop() {
m_DestinationAfterKnockback.reset();
if (AtFinalWaypoint()) return;
SetPosition(ApproximateLocation());
@@ -299,6 +320,7 @@ void MovementAIComponent::Stop() {
}
void MovementAIComponent::PullToPoint(const NiPoint3& point) {
m_Knockback.Cancel();
Stop();
m_PullingToPoint = true;
@@ -405,6 +427,10 @@ void MovementAIComponent::SetVelocity(const NiPoint3& value) {
void MovementAIComponent::SetDestination(const NiPoint3 destination) {
if (m_PullingToPoint) return;
if (IsKnockedBack()) {
m_DestinationAfterKnockback = destination;
return;
}
const auto location = ApproximateLocation();
@@ -598,3 +624,65 @@ void MovementAIComponent::FollowTarget(const LWOOBJID target) {
m_CurrentSpeed = 1.0f;
SetDestination(getPos.pos);
}
void MovementAIComponent::Knockback(const NiPoint3& vector) {
// Whatever we were walking to is forgotten, like a knocked back character losing its movement input
m_PullingToPoint = false;
Stop();
const auto position = m_Parent->GetPosition();
m_KnockbackStartHeight = position.y;
auto* const controllablePhysics = m_Parent->GetComponent<ControllablePhysicsComponent>();
const auto ground = [this](const NiPoint3& point) { return GetGroundHeight(point); };
if (!m_Knockback.Start(position, vector)) {
// Too weak to launch: just shoved along the ground
SetPosition(dpKnockback::Nudge(position, vector, KNOCKBACK_MAX_DROP, ground));
Game::entityManager->SerializeEntity(m_Parent);
return;
}
SetPosition(m_Knockback.GetPosition());
SetVelocity(m_Knockback.GetVelocity());
if (controllablePhysics) controllablePhysics->SetIsOnGround(false);
Game::entityManager->SerializeEntity(m_Parent);
}
void MovementAIComponent::UpdateKnockback(const float deltaTime) {
auto* const controllablePhysics = m_Parent->GetComponent<ControllablePhysicsComponent>();
auto gravity = Game::zoneManager ? Game::zoneManager->GetWorldConfig().peGravityValue : 0.0f;
if (gravity <= 0.0f) gravity = DEFAULT_GRAVITY;
if (controllablePhysics && controllablePhysics->GetGravityScale() > 0.0f) gravity *= controllablePhysics->GetGravityScale();
const bool flying = m_Knockback.Step(deltaTime, gravity, [this](const NiPoint3& point) { return GetGroundHeight(point); });
if (flying) {
SetPosition(m_Knockback.GetPosition());
SetVelocity(m_Knockback.GetVelocity());
Game::entityManager->SerializeEntity(m_Parent);
return;
}
// Landed: put it back on the navmesh so pathing picks up from a walkable spot
auto landing = m_Knockback.GetPosition();
if (dpWorld::IsLoaded()) landing = dpWorld::GetNavMesh()->NearestPoint(landing);
SetPosition(landing);
SetVelocity(NiPoint3Constant::ZERO);
if (controllablePhysics) controllablePhysics->SetIsOnGround(true);
m_SourcePosition = landing;
m_NextWaypoint = landing;
Game::entityManager->SerializeEntity(m_Parent);
if (m_DestinationAfterKnockback) {
const auto destination = *m_DestinationAfterKnockback;
m_DestinationAfterKnockback.reset();
if (!m_Paused) SetDestination(destination);
}
}
float MovementAIComponent::GetGroundHeight(const NiPoint3& point) const {
if (dpWorld::IsLoaded()) return dpWorld::GetNavMesh()->GetHeightAtPoint(point);
return m_KnockbackStartHeight;
}

View File

@@ -15,6 +15,8 @@
#include "Component.h"
#include "eReplicaComponentType.h"
#include "Zone.h"
#include "dpKnockback.h"
#include <optional>
#include <vector>
class ControllablePhysicsComponent;
@@ -223,8 +225,31 @@ public:
bool HasPath() const { return m_Path != nullptr; }
void FollowTarget(const LWOOBJID target);
/**
* Knocks this entity back the way the client knocks back the character it controls (the Knockback game
* message): a vector longer than dpKnockback::MIN_LAUNCH_SPEED throws it in an arc under the zone's gravity
* until it lands on the navmesh, a shorter one moves it by the vector. Pathing waits until it lands.
* @param vector the knockback velocity
*/
void Knockback(const NiPoint3& vector);
/**
* Returns whether this entity is flying from a knockback
*/
bool IsKnockedBack() const { return m_Knockback.IsActive(); }
private:
/**
* Moves the entity along its knockback arc, landing it when it reaches the ground
*/
void UpdateKnockback(float deltaTime);
/**
* The height of the ground under a point: the navmesh if the zone has one, else the height the knockback started at
*/
float GetGroundHeight(const NiPoint3& point) const;
/**
* @brief
* Runs the commands on a waypoint if a path exists
@@ -348,6 +373,21 @@ private:
uint32_t m_CurrentPathWaypointCount{ 0 };
LWOOBJID m_FollowedTarget{ LWOOBJID_EMPTY };
/**
* The knockback this entity is flying through, if any
*/
dpKnockback::Arc m_Knockback;
/**
* The height the knockback started at, the ground used when the zone has no navmesh
*/
float m_KnockbackStartHeight{};
/**
* A destination set while flying, walked to after landing
*/
std::optional<NiPoint3> m_DestinationAfterKnockback;
};
#endif // MOVEMENTAICOMPONENT_H