diff --git a/dGame/dBehaviors/KnockbackBehavior.cpp b/dGame/dBehaviors/KnockbackBehavior.cpp index 4d7bf2ead..3b1c7bbc9 100644 --- a/dGame/dBehaviors/KnockbackBehavior.cpp +++ b/dGame/dBehaviors/KnockbackBehavior.cpp @@ -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(); 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(); + if (!movementAI) return; + + const auto* const destroyableComponent = knocked->GetComponent(); + 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"); } diff --git a/dGame/dBehaviors/KnockbackBehavior.h b/dGame/dBehaviors/KnockbackBehavior.h index 89777a6d8..6bc862fa7 100644 --- a/dGame/dBehaviors/KnockbackBehavior.h +++ b/dGame/dBehaviors/KnockbackBehavior.h @@ -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; }; diff --git a/dGame/dComponents/BaseCombatAIComponent.cpp b/dGame/dComponents/BaseCombatAIComponent.cpp index 5bc74f6e9..a86fb9b5d 100644 --- a/dGame/dComponents/BaseCombatAIComponent.cpp +++ b/dGame/dComponents/BaseCombatAIComponent.cpp @@ -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; diff --git a/dGame/dComponents/DestroyableComponent.h b/dGame/dComponents/DestroyableComponent.h index 820deee55..5091e9703 100644 --- a/dGame/dComponents/DestroyableComponent.h +++ b/dGame/dComponents/DestroyableComponent.h @@ -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; } diff --git a/dGame/dComponents/MovementAIComponent.cpp b/dGame/dComponents/MovementAIComponent.cpp index dbb623a07..73987970b 100644 --- a/dGame/dComponents/MovementAIComponent.cpp +++ b/dGame/dComponents/MovementAIComponent.cpp @@ -31,6 +31,13 @@ namespace { * Cache of all lots and their respective speeds */ std::map 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(); @@ -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(); + 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(); + + 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; +} diff --git a/dGame/dComponents/MovementAIComponent.h b/dGame/dComponents/MovementAIComponent.h index f19007fc7..afd4fa5a6 100644 --- a/dGame/dComponents/MovementAIComponent.h +++ b/dGame/dComponents/MovementAIComponent.h @@ -15,6 +15,8 @@ #include "Component.h" #include "eReplicaComponentType.h" #include "Zone.h" +#include "dpKnockback.h" +#include #include 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 m_DestinationAfterKnockback; }; #endif // MOVEMENTAICOMPONENT_H diff --git a/dPhysics/CMakeLists.txt b/dPhysics/CMakeLists.txt index 928ff32b6..508bcd16d 100644 --- a/dPhysics/CMakeLists.txt +++ b/dPhysics/CMakeLists.txt @@ -1,6 +1,7 @@ set(DPHYSICS_SOURCES "dpCollisionChecks.cpp" "dpEntity.cpp" "dpGrid.cpp" + "dpKnockback.cpp" "dpShapeBase.cpp" "dpShapeBox.cpp" "dpShapeSphere.cpp" diff --git a/dPhysics/dpKnockback.cpp b/dPhysics/dpKnockback.cpp new file mode 100644 index 000000000..d2770cdf8 --- /dev/null +++ b/dPhysics/dpKnockback.cpp @@ -0,0 +1,95 @@ +#include "dpKnockback.h" + +#include +#include +#include + +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 / 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; +} diff --git a/dPhysics/dpKnockback.h b/dPhysics/dpKnockback.h new file mode 100644 index 000000000..94b9f0aea --- /dev/null +++ b/dPhysics/dpKnockback.h @@ -0,0 +1,84 @@ +#ifndef DPKNOCKBACK_H +#define DPKNOCKBACK_H + +#include + +#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; + + 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 diff --git a/dScripts/02_server/Map/AM/AmShieldGenerator.cpp b/dScripts/02_server/Map/AM/AmShieldGenerator.cpp index 55bc315e8..29b09e12e 100644 --- a/dScripts/02_server/Map/AM/AmShieldGenerator.cpp +++ b/dScripts/02_server/Map/AM/AmShieldGenerator.cpp @@ -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(); } diff --git a/dScripts/02_server/Map/AM/AmShieldGeneratorQuickbuild.cpp b/dScripts/02_server/Map/AM/AmShieldGeneratorQuickbuild.cpp index 9fa606dca..68ed26ff0 100644 --- a/dScripts/02_server/Map/AM/AmShieldGeneratorQuickbuild.cpp +++ b/dScripts/02_server/Map/AM/AmShieldGeneratorQuickbuild.cpp @@ -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(); } diff --git a/tests/dGameTests/CMakeLists.txt b/tests/dGameTests/CMakeLists.txt index 4b1dd4ed1..a48269c3a 100644 --- a/tests/dGameTests/CMakeLists.txt +++ b/tests/dGameTests/CMakeLists.txt @@ -7,6 +7,7 @@ set(DGAMETEST_SOURCES "SlashCommandPermissionTests.cpp" "StaleSaveGuardTests.cpp" "ContrabandTests.cpp" + "KnockbackTests.cpp" ) add_subdirectory(dComponentsTests) diff --git a/tests/dGameTests/KnockbackTests.cpp b/tests/dGameTests/KnockbackTests.cpp new file mode 100644 index 000000000..259e2e2fb --- /dev/null +++ b/tests/dGameTests/KnockbackTests.cpp @@ -0,0 +1,155 @@ +#include + +#include + +#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); +}