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

@@ -1,6 +1,7 @@
set(DPHYSICS_SOURCES "dpCollisionChecks.cpp"
"dpEntity.cpp"
"dpGrid.cpp"
"dpKnockback.cpp"
"dpShapeBase.cpp"
"dpShapeBox.cpp"
"dpShapeSphere.cpp"

95
dPhysics/dpKnockback.cpp Normal file
View File

@@ -0,0 +1,95 @@
#include "dpKnockback.h"
#include <algorithm>
#include <cmath>
#include <numbers>
NiPoint3 dpKnockback::ComputeVector(const NiPoint3& away, const float angleDegrees, const float strength) {
NiPoint3 direction{ 0.0f, 1.0f, 0.0f };
// Anything but (nearly) straight up: the flat direction raised by the angle
if (angleDegrees <= 89.0f || angleDegrees >= 91.0f) {
direction = NiPoint3(away.x, 0.0f, away.z);
const auto flatLength = direction.Length();
direction = flatLength > 0.0f ? direction / flatLength : NiPoint3Constant::ZERO;
direction.y += std::tan(angleDegrees * std::numbers::pi_v<float> / 180.0f);
const auto length = direction.Length();
direction = length > 0.0f ? direction / length : NiPoint3Constant::ZERO;
}
return direction * std::min(strength, MAX_STRENGTH);
}
bool dpKnockback::Arc::Start(const NiPoint3& position, const NiPoint3& vector) {
m_Elapsed = 0.0f;
if (vector.SquaredLength() <= MIN_LAUNCH_SPEED * MIN_LAUNCH_SPEED) {
m_Active = false;
m_Position = position;
m_Velocity = NiPoint3Constant::ZERO;
return false;
}
m_Active = true;
m_Position = position;
m_Position.y += LAUNCH_LIFT;
m_Velocity = vector;
return true;
}
bool dpKnockback::Arc::Step(float deltaTime, const float gravity, const GroundQuery& ground) {
while (m_Active && deltaTime > 0.0f) {
const auto step = std::min(deltaTime, MAX_SUB_STEP);
deltaTime -= step;
if (!SubStep(step, gravity, ground)) m_Active = false;
}
return m_Active;
}
bool dpKnockback::Arc::SubStep(const float deltaTime, const float gravity, const GroundQuery& ground) {
m_Elapsed += deltaTime;
// Semi-implicit Euler, like the character controller: velocity first, then position
m_Velocity.y -= gravity * deltaTime;
auto next = m_Position + m_Velocity * deltaTime;
auto groundHeight = ground(next);
if (groundHeight - next.y > MAX_STEP_HEIGHT && groundHeight - m_Position.y > MAX_STEP_HEIGHT) {
// Flew into something taller than a step: stop going sideways and keep falling where we are
m_Velocity.x = 0.0f;
m_Velocity.z = 0.0f;
next.x = m_Position.x;
next.z = m_Position.z;
groundHeight = ground(next);
}
m_Position = next;
const bool falling = m_Velocity.y <= 0.0f;
if (falling && m_Position.y <= groundHeight && m_Elapsed >= MIN_AIRBORNE_TIME) {
m_Position.y = groundHeight;
m_Velocity = NiPoint3Constant::ZERO;
return false;
}
// Below the ground before the minimum time is up (or still going up): ride along the ground
if (m_Position.y < groundHeight) {
m_Position.y = groundHeight;
if (m_Velocity.y < 0.0f) m_Velocity.y = 0.0f;
}
if (m_Elapsed >= MAX_AIRBORNE_TIME) {
m_Velocity = NiPoint3Constant::ZERO;
return false;
}
return true;
}
NiPoint3 dpKnockback::Nudge(const NiPoint3& position, const NiPoint3& vector, const float maxDrop, const GroundQuery& ground) {
auto moved = position + vector;
const auto groundHeight = ground(moved);
if (std::abs(moved.y - groundHeight) > maxDrop) return position;
moved.y = groundHeight;
return moved;
}

84
dPhysics/dpKnockback.h Normal file
View File

@@ -0,0 +1,84 @@
#ifndef DPKNOCKBACK_H
#define DPKNOCKBACK_H
#include <functional>
#include "NiPoint3.h"
/**
* Server side knockback for objects the server moves (enemies, NPCs). The client only simulates knockbacks on the
* character it controls (LWOControllablePhysComponent::msgKnockback, 1.10.64 0x00cf5610); everything else is drawn
* where the server serializes it, so the server has to fly the object itself. This follows the client:
*
* - KnockbackBehavior::Cast (0x004efc10) builds the vector: the flat direction away from the source, raised by
* `angle` degrees (tan(angle) added to the unit flat direction, then unitized), times `strength` capped at 300.
* An angle strictly between 89 and 91 degrees knocks straight up.
* - msgKnockback: a vector longer than 5 lifts the character 0.5 up, sets its velocity to the vector and lets the
* character controller fall under gravity (WorldConfig.pegravityvalue) until it lands, no sooner than 250ms later.
* A shorter vector just moves the character by the vector, dropped onto the ground if it's within half its height.
*/
namespace dpKnockback {
// Vectors at or below this length move the object instead of launching it (0x0151370c)
constexpr float MIN_LAUNCH_SPEED = 5.0f;
// How far the object is lifted when launched (0x01479c58)
constexpr float LAUNCH_LIFT = 0.5f;
// The client keeps the knockback state at least this long (msgKnockback: knockbackExpire = now + 250)
constexpr float MIN_AIRBORNE_TIME = 0.25f;
// KnockbackBehavior caps strength at this (0x01513c38)
constexpr float MAX_STRENGTH = 300.0f;
// Ground this much above the object is a wall it can't pass, lower ground is landed on
constexpr float MAX_STEP_HEIGHT = 1.0f;
// Longest integration step, so fast knockbacks don't skip over walls
constexpr float MAX_SUB_STEP = 1.0f / 60.0f;
// Give up after this long in the air (knocked off the world)
constexpr float MAX_AIRBORNE_TIME = 10.0f;
/**
* The knockback vector KnockbackBehavior sends
* @param away the direction to knock towards (only its x and z are used)
* @param angleDegrees the elevation above the ground plane, the behavior's `angle`
* @param strength the behavior's `strength`
*/
[[nodiscard]] NiPoint3 ComputeVector(const NiPoint3& away, float angleDegrees, float strength);
// Returns the ground height under a point
using GroundQuery = std::function<float(const NiPoint3&)>;
class Arc {
public:
/**
* Starts a knockback. Returns false when the vector is short enough that the object should just be moved
* by it (see Nudge) instead of flying.
*/
bool Start(const NiPoint3& position, const NiPoint3& vector);
/**
* Advances the flight by deltaTime under the given gravity (a positive number pulling down)
* @return true while still in the air
*/
bool Step(float deltaTime, float gravity, const GroundQuery& ground);
[[nodiscard]] bool IsActive() const { return m_Active; }
[[nodiscard]] const NiPoint3& GetPosition() const { return m_Position; }
[[nodiscard]] const NiPoint3& GetVelocity() const { return m_Velocity; }
[[nodiscard]] float GetElapsed() const { return m_Elapsed; }
void Cancel() { m_Active = false; m_Velocity = NiPoint3Constant::ZERO; }
private:
bool SubStep(float deltaTime, float gravity, const GroundQuery& ground);
NiPoint3 m_Position{};
NiPoint3 m_Velocity{};
float m_Elapsed{};
bool m_Active{};
};
/**
* A short knockback: the position moved by the vector, dropped onto the ground when the ground is within
* maxDrop of it; otherwise the object stays where it is (the client's msgKnockback does the same).
*/
[[nodiscard]] NiPoint3 Nudge(const NiPoint3& position, const NiPoint3& vector, float maxDrop, const GroundQuery& ground);
};
#endif //!DPKNOCKBACK_H