mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 02:43:44 +00:00
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:
@@ -8,6 +8,8 @@
|
||||
#include "DestroyableComponent.h"
|
||||
#include "Game.h"
|
||||
#include "Logger.h"
|
||||
#include "MovementAIComponent.h"
|
||||
#include "dpKnockback.h"
|
||||
|
||||
void KnockbackBehavior::Handle(BehaviorContext* context, RakNet::BitStream& bitStream, BehaviorBranchContext branch) {
|
||||
bool unknown{};
|
||||
@@ -16,6 +18,9 @@ void KnockbackBehavior::Handle(BehaviorContext* context, RakNet::BitStream& bitS
|
||||
LOG("Unable to read unknown from bitStream, aborting Handle! %i", bitStream.GetNumberOfUnreadBits());
|
||||
return;
|
||||
};
|
||||
|
||||
// The bit is whether the target blocked it
|
||||
if (!unknown) KnockbackServerObject(context, branch);
|
||||
}
|
||||
|
||||
void KnockbackBehavior::Calculate(BehaviorContext* context, RakNet::BitStream& bitStream, BehaviorBranchContext branch) {
|
||||
@@ -27,16 +32,53 @@ void KnockbackBehavior::Calculate(BehaviorContext* context, RakNet::BitStream& b
|
||||
auto* destroyableComponent = target->GetComponent<DestroyableComponent>();
|
||||
|
||||
if (destroyableComponent != nullptr) {
|
||||
blocked = destroyableComponent->IsKnockbackImmune();
|
||||
// The client knocks back its own character unless this bit is set, then checks its status immunity.
|
||||
// Answer for the immunity here too, so a client that hasn't caught up (e.g. the Personal Fortress
|
||||
// that was just raised) can't knock itself back anyway.
|
||||
blocked = destroyableComponent->IsKnockbackImmune() || destroyableComponent->GetImmuneToKnockback();
|
||||
}
|
||||
}
|
||||
|
||||
bitStream.Write(blocked);
|
||||
|
||||
if (!blocked) KnockbackServerObject(context, branch);
|
||||
}
|
||||
|
||||
void KnockbackBehavior::KnockbackServerObject(BehaviorContext* context, const BehaviorBranchContext& branch) const {
|
||||
// Mirrors KnockbackBehavior::Cast in the client (1.10.64 0x004efc10)
|
||||
if (m_ignoreSelf && context->caster == branch.target) return;
|
||||
|
||||
// `caster` knocks back the caster, away from the target
|
||||
const auto knockedID = m_caster ? context->caster : branch.target;
|
||||
const auto sourceID = m_caster ? branch.target : context->caster;
|
||||
|
||||
auto* const knocked = Game::entityManager->GetEntity(knockedID);
|
||||
if (!knocked || knocked->IsPlayer()) return;
|
||||
|
||||
auto* const movementAI = knocked->GetComponent<MovementAIComponent>();
|
||||
if (!movementAI) return;
|
||||
|
||||
const auto* const destroyableComponent = knocked->GetComponent<DestroyableComponent>();
|
||||
if (destroyableComponent && destroyableComponent->GetImmuneToKnockback()) return;
|
||||
|
||||
NiPoint3 away;
|
||||
if (m_relative) {
|
||||
// Backwards from where the knocked object faces
|
||||
away = QuatUtils::Forward(knocked->GetRotation()) * -1.0f;
|
||||
} else {
|
||||
const auto* const source = Game::entityManager->GetEntity(sourceID);
|
||||
if (!source) return;
|
||||
away = knocked->GetPosition() - source->GetPosition();
|
||||
}
|
||||
|
||||
movementAI->Knockback(dpKnockback::ComputeVector(away, m_angle, m_strength));
|
||||
}
|
||||
|
||||
void KnockbackBehavior::Load() {
|
||||
this->m_strength = GetInt("strength");
|
||||
this->m_angle = GetInt("angle");
|
||||
this->m_strength = GetFloat("strength");
|
||||
this->m_angle = GetFloat("angle");
|
||||
this->m_relative = GetBoolean("relative");
|
||||
this->m_time = GetInt("time_ms");
|
||||
this->m_ignoreSelf = GetBoolean("ignore_self", true);
|
||||
this->m_caster = GetBoolean("caster");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#pragma once
|
||||
#include "Behavior.h"
|
||||
|
||||
class Entity;
|
||||
|
||||
class KnockbackBehavior final : public Behavior
|
||||
{
|
||||
public:
|
||||
@@ -8,10 +10,12 @@ public:
|
||||
* Inherited
|
||||
*/
|
||||
|
||||
uint32_t m_strength;
|
||||
uint32_t m_angle;
|
||||
float m_strength;
|
||||
float m_angle;
|
||||
bool m_relative;
|
||||
uint32_t m_time;
|
||||
bool m_ignoreSelf;
|
||||
bool m_caster;
|
||||
|
||||
|
||||
explicit KnockbackBehavior(const uint32_t behaviorID) : Behavior(behaviorID) {
|
||||
@@ -22,4 +26,11 @@ public:
|
||||
void Calculate(BehaviorContext* context, RakNet::BitStream& bitStream, BehaviorBranchContext branch) override;
|
||||
|
||||
void Load() override;
|
||||
|
||||
private:
|
||||
/**
|
||||
* Knocks back the target if the server moves it. Players knock themselves back on their own client when it
|
||||
* runs the skill; enemies and NPCs are only drawn where the server puts them, so the server flies them.
|
||||
*/
|
||||
void KnockbackServerObject(BehaviorContext* context, const BehaviorBranchContext& branch) const;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
95
dPhysics/dpKnockback.cpp
Normal 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
84
dPhysics/dpKnockback.h
Normal 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
|
||||
@@ -130,17 +130,16 @@ void AmShieldGenerator::EnemyEnteredShield(Entity* self, Entity* intruder) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Knock the enemy back the way it came
|
||||
auto dir = QuatUtils::Forward(intruder->GetRotation()) * -1;
|
||||
dir.y += 15;
|
||||
dir.x *= 50;
|
||||
dir.z *= 50;
|
||||
|
||||
// TODO: Figure out how todo knockback, I'll stun them for now
|
||||
|
||||
if (NiPoint3::DistanceSquared(self->GetPosition(), intruder->GetPosition()) < 20 * 20) {
|
||||
baseCombatAIComponent->Stun(2.0f);
|
||||
movementAIComponent->SetDestination(baseCombatAIComponent->GetStartPosition());
|
||||
movementAIComponent->Knockback(dir);
|
||||
}
|
||||
|
||||
// Forget the players so it doesn't just run into the shield again
|
||||
baseCombatAIComponent->ClearThreat();
|
||||
}
|
||||
|
||||
@@ -188,17 +188,16 @@ void AmShieldGeneratorQuickbuild::EnemyEnteredShield(Entity* self, Entity* intru
|
||||
return;
|
||||
}
|
||||
|
||||
// Knock the enemy back the way it came
|
||||
auto dir = QuatUtils::Forward(intruder->GetRotation()) * -1;
|
||||
dir.y += 15;
|
||||
dir.x *= 50;
|
||||
dir.z *= 50;
|
||||
|
||||
// TODO: Figure out how todo knockback, I'll stun them for now
|
||||
|
||||
if (NiPoint3::DistanceSquared(self->GetPosition(), intruder->GetPosition()) < 20 * 20) {
|
||||
baseCombatAIComponent->Stun(2.0f);
|
||||
movementAIComponent->SetDestination(baseCombatAIComponent->GetStartPosition());
|
||||
movementAIComponent->Knockback(dir);
|
||||
}
|
||||
|
||||
// Forget the players so it doesn't just run into the shield again
|
||||
baseCombatAIComponent->ClearThreat();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ set(DGAMETEST_SOURCES
|
||||
"SlashCommandPermissionTests.cpp"
|
||||
"StaleSaveGuardTests.cpp"
|
||||
"ContrabandTests.cpp"
|
||||
"KnockbackTests.cpp"
|
||||
)
|
||||
|
||||
add_subdirectory(dComponentsTests)
|
||||
|
||||
155
tests/dGameTests/KnockbackTests.cpp
Normal file
155
tests/dGameTests/KnockbackTests.cpp
Normal file
@@ -0,0 +1,155 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cmath>
|
||||
|
||||
#include "dpKnockback.h"
|
||||
|
||||
namespace {
|
||||
constexpr float GRAVITY = 57.0f; // WorldConfig.pegravityvalue
|
||||
constexpr float TICK = 1.0f / 30.0f;
|
||||
|
||||
const dpKnockback::GroundQuery flatGround = [](const NiPoint3&) { return 0.0f; };
|
||||
|
||||
// Flies the arc to the end, returns how long it took
|
||||
float Fly(dpKnockback::Arc& arc, const dpKnockback::GroundQuery& ground, float tick = TICK) {
|
||||
float time = 0.0f;
|
||||
while (arc.Step(tick, GRAVITY, ground)) {
|
||||
time += tick;
|
||||
if (time > 20.0f) break;
|
||||
}
|
||||
return time + tick;
|
||||
}
|
||||
}
|
||||
|
||||
TEST(KnockbackTests, VectorMatchesTheClient) {
|
||||
// Flat direction away from the source, raised by angle degrees
|
||||
const auto vector = dpKnockback::ComputeVector(NiPoint3(10.0f, 3.0f, 0.0f), 45.0f, 20.0f);
|
||||
EXPECT_NEAR(vector.x, 20.0f / std::sqrt(2.0f), 1e-3f);
|
||||
EXPECT_NEAR(vector.y, 20.0f / std::sqrt(2.0f), 1e-3f);
|
||||
EXPECT_NEAR(vector.z, 0.0f, 1e-5f);
|
||||
|
||||
// The dragon's slam: angle 65, strength 55
|
||||
const auto slam = dpKnockback::ComputeVector(NiPoint3(0.0f, 0.0f, -4.0f), 65.0f, 55.0f);
|
||||
EXPECT_NEAR(slam.Length(), 55.0f, 1e-3f);
|
||||
EXPECT_NEAR(std::atan2(slam.y, -slam.z) * 180.0f / 3.14159265f, 65.0f, 1e-2f);
|
||||
}
|
||||
|
||||
TEST(KnockbackTests, NinetyDegreesIsStraightUp) {
|
||||
const auto up = dpKnockback::ComputeVector(NiPoint3(5.0f, 0.0f, 5.0f), 90.0f, 40.0f);
|
||||
EXPECT_EQ(up, NiPoint3(0.0f, 40.0f, 0.0f));
|
||||
|
||||
// Without a direction the client still knocks straight up for anything near 90
|
||||
EXPECT_EQ(dpKnockback::ComputeVector(NiPoint3Constant::ZERO, 89.5f, 10.0f), NiPoint3(0.0f, 10.0f, 0.0f));
|
||||
}
|
||||
|
||||
TEST(KnockbackTests, StrengthIsCapped) {
|
||||
const auto vector = dpKnockback::ComputeVector(NiPoint3(1.0f, 0.0f, 0.0f), 0.0f, 1000.0f);
|
||||
EXPECT_NEAR(vector.x, dpKnockback::MAX_STRENGTH, 1e-3f);
|
||||
EXPECT_NEAR(vector.y, 0.0f, 1e-5f);
|
||||
}
|
||||
|
||||
TEST(KnockbackTests, ShortVectorsDontLaunch) {
|
||||
dpKnockback::Arc arc;
|
||||
EXPECT_FALSE(arc.Start(NiPoint3(1.0f, 2.0f, 3.0f), NiPoint3(3.0f, 0.0f, 4.0f))); // length 5
|
||||
EXPECT_FALSE(arc.IsActive());
|
||||
|
||||
// Moved by the vector and dropped onto the ground when it's close
|
||||
const auto moved = dpKnockback::Nudge(NiPoint3(0.0f, 0.5f, 0.0f), NiPoint3(3.0f, 0.0f, 0.0f), 2.0f, flatGround);
|
||||
EXPECT_EQ(moved, NiPoint3(3.0f, 0.0f, 0.0f));
|
||||
|
||||
// Ground too far below (a ledge): stays put, like the client
|
||||
const auto ledge = dpKnockback::Nudge(NiPoint3(0.0f, 10.0f, 0.0f), NiPoint3(3.0f, 0.0f, 0.0f), 2.0f, flatGround);
|
||||
EXPECT_EQ(ledge, NiPoint3(0.0f, 10.0f, 0.0f));
|
||||
}
|
||||
|
||||
TEST(KnockbackTests, ArcFollowsBallistics) {
|
||||
// Straight up at 57 u/s under 57 u/s^2 rises for 1s, peaks at 28.5 above the 0.5 lift, lands after ~2s
|
||||
dpKnockback::Arc arc;
|
||||
ASSERT_TRUE(arc.Start(NiPoint3Constant::ZERO, NiPoint3(0.0f, 57.0f, 0.0f)));
|
||||
EXPECT_FLOAT_EQ(arc.GetPosition().y, dpKnockback::LAUNCH_LIFT);
|
||||
|
||||
float peak = 0.0f;
|
||||
float time = 0.0f;
|
||||
while (arc.Step(TICK, GRAVITY, flatGround)) {
|
||||
time += TICK;
|
||||
peak = std::max(peak, arc.GetPosition().y);
|
||||
}
|
||||
EXPECT_NEAR(peak, 28.5f + dpKnockback::LAUNCH_LIFT, 1.0f);
|
||||
EXPECT_NEAR(time, 2.0f, 0.1f);
|
||||
EXPECT_FLOAT_EQ(arc.GetPosition().y, 0.0f);
|
||||
EXPECT_EQ(arc.GetVelocity(), NiPoint3Constant::ZERO);
|
||||
}
|
||||
|
||||
TEST(KnockbackTests, HorizontalDistanceMatchesFlightTime) {
|
||||
// The shield generator: 50 back, 15 up
|
||||
dpKnockback::Arc arc;
|
||||
ASSERT_TRUE(arc.Start(NiPoint3Constant::ZERO, NiPoint3(-50.0f, 15.0f, 0.0f)));
|
||||
const auto time = Fly(arc, flatGround);
|
||||
|
||||
// t = (vy + sqrt(vy^2 + 2 g lift)) / g
|
||||
const float expected = (15.0f + std::sqrt(15.0f * 15.0f + 2.0f * GRAVITY * dpKnockback::LAUNCH_LIFT)) / GRAVITY;
|
||||
EXPECT_NEAR(time, expected, 2 * TICK);
|
||||
EXPECT_NEAR(arc.GetPosition().x, -50.0f * expected, 50.0f * 2 * TICK);
|
||||
EXPECT_FLOAT_EQ(arc.GetPosition().y, 0.0f);
|
||||
}
|
||||
|
||||
TEST(KnockbackTests, TickRateDoesntChangeTheLanding) {
|
||||
dpKnockback::Arc slow;
|
||||
dpKnockback::Arc fast;
|
||||
ASSERT_TRUE(slow.Start(NiPoint3Constant::ZERO, NiPoint3(20.0f, 30.0f, 10.0f)));
|
||||
ASSERT_TRUE(fast.Start(NiPoint3Constant::ZERO, NiPoint3(20.0f, 30.0f, 10.0f)));
|
||||
Fly(slow, flatGround, 0.1f);
|
||||
Fly(fast, flatGround, 1.0f / 120.0f);
|
||||
EXPECT_NEAR(slow.GetPosition().x, fast.GetPosition().x, 0.5f);
|
||||
EXPECT_NEAR(slow.GetPosition().z, fast.GetPosition().z, 0.5f);
|
||||
}
|
||||
|
||||
TEST(KnockbackTests, FlatKnockbackLastsTheMinimumTime) {
|
||||
// Mostly sideways: lands from the 0.5 lift almost at once but the client keeps the state 250ms
|
||||
dpKnockback::Arc arc;
|
||||
ASSERT_TRUE(arc.Start(NiPoint3Constant::ZERO, NiPoint3(0.0f, 0.0f, 40.0f)));
|
||||
while (arc.Step(1.0f / 100.0f, GRAVITY, flatGround)) {}
|
||||
EXPECT_GE(arc.GetElapsed(), dpKnockback::MIN_AIRBORNE_TIME);
|
||||
EXPECT_LT(arc.GetElapsed(), dpKnockback::MIN_AIRBORNE_TIME + 0.02f);
|
||||
EXPECT_NEAR(arc.GetPosition().z, 40.0f * arc.GetElapsed(), 0.5f);
|
||||
EXPECT_FLOAT_EQ(arc.GetPosition().y, 0.0f);
|
||||
}
|
||||
|
||||
TEST(KnockbackTests, WallsStopSidewaysMotion) {
|
||||
// A 10 tall wall at x = 5
|
||||
const dpKnockback::GroundQuery wall = [](const NiPoint3& point) { return point.x >= 5.0f ? 10.0f : 0.0f; };
|
||||
dpKnockback::Arc arc;
|
||||
ASSERT_TRUE(arc.Start(NiPoint3Constant::ZERO, NiPoint3(40.0f, 10.0f, 0.0f)));
|
||||
Fly(arc, wall);
|
||||
EXPECT_LT(arc.GetPosition().x, 5.0f);
|
||||
EXPECT_FLOAT_EQ(arc.GetPosition().y, 0.0f);
|
||||
}
|
||||
|
||||
TEST(KnockbackTests, LandsOnLowerGround) {
|
||||
// Knocked off a 5 high ledge at x = 2
|
||||
const dpKnockback::GroundQuery ledge = [](const NiPoint3& point) { return point.x < 2.0f ? 5.0f : 0.0f; };
|
||||
dpKnockback::Arc arc;
|
||||
ASSERT_TRUE(arc.Start(NiPoint3(0.0f, 5.0f, 0.0f), NiPoint3(10.0f, 5.0f, 0.0f)));
|
||||
Fly(arc, ledge);
|
||||
EXPECT_GT(arc.GetPosition().x, 2.0f);
|
||||
EXPECT_FLOAT_EQ(arc.GetPosition().y, 0.0f);
|
||||
}
|
||||
|
||||
TEST(KnockbackTests, StepsUpOntoLowRises) {
|
||||
// A 0.5 step at x = 1 is walked onto, not treated as a wall
|
||||
const dpKnockback::GroundQuery step = [](const NiPoint3& point) { return point.x >= 1.0f ? 0.5f : 0.0f; };
|
||||
dpKnockback::Arc arc;
|
||||
ASSERT_TRUE(arc.Start(NiPoint3Constant::ZERO, NiPoint3(20.0f, 0.0f, 0.0f)));
|
||||
Fly(arc, step);
|
||||
EXPECT_GT(arc.GetPosition().x, 1.0f);
|
||||
EXPECT_FLOAT_EQ(arc.GetPosition().y, 0.5f);
|
||||
}
|
||||
|
||||
TEST(KnockbackTests, BottomlessFallsGiveUp) {
|
||||
const dpKnockback::GroundQuery nothing = [](const NiPoint3&) { return -100000.0f; };
|
||||
dpKnockback::Arc arc;
|
||||
ASSERT_TRUE(arc.Start(NiPoint3Constant::ZERO, NiPoint3(0.0f, 10.0f, 0.0f)));
|
||||
Fly(arc, nothing);
|
||||
EXPECT_FALSE(arc.IsActive());
|
||||
EXPECT_NEAR(arc.GetElapsed(), dpKnockback::MAX_AIRBORNE_TIME, 0.05f);
|
||||
}
|
||||
Reference in New Issue
Block a user