mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 02:43:44 +00:00
feat(physics): movement blockers the server's movers can't walk through
Enemies are moved along navmesh paths without collision, so a wall only stops them if their path stops at it. dpWorld now keeps a list of such walls, each with the collision filter it blocks with, and cuts a path where it first walks into one (a little short of the wall; a mover inside one can walk out). Which objects block comes from data (dpMovementBlockers::BlockingFilter): - navmesh carvers (navmesh_carver in the level config, which the client reads with add_to_navmesh and carver_only) block every mover; - solid objects whose collision group touches enemies and not players (group 18, e.g. "FV - Enemy Blocking Volume", "PR - Pet Blocker") block what that group touches. dpShapeBox gains SegmentEntry, a segment test against the rotated box. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ set(DPHYSICS_SOURCES "dpCollisionChecks.cpp"
|
||||
"dpCollisionFilter.cpp"
|
||||
"dpGrid.cpp"
|
||||
"dpKnockback.cpp"
|
||||
"dpMovementBlockers.cpp"
|
||||
"dpShapeBase.cpp"
|
||||
"dpShapeBox.cpp"
|
||||
"dpShapeSphere.cpp"
|
||||
|
||||
@@ -41,6 +41,7 @@ public:
|
||||
void SetAngularVelocity(const NiPoint3& newAngularVelocity);
|
||||
|
||||
dpShapeBase* GetShape() { return m_CollisionShape; }
|
||||
const dpShapeBase* GetShape() const { return m_CollisionShape; }
|
||||
|
||||
bool GetIsStatic() const { return m_IsStatic; }
|
||||
|
||||
|
||||
64
dPhysics/dpMovementBlockers.cpp
Normal file
64
dPhysics/dpMovementBlockers.cpp
Normal file
@@ -0,0 +1,64 @@
|
||||
#include "dpMovementBlockers.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "dpCollisionFilter.h"
|
||||
#include "dpEntity.h"
|
||||
#include "dpShapeBox.h"
|
||||
|
||||
namespace {
|
||||
constexpr uint32_t PLAYER_GROUP = 10;
|
||||
constexpr uint32_t ENEMY_GROUP = 12;
|
||||
}
|
||||
|
||||
std::optional<uint32_t> dpMovementBlockers::BlockingFilter(const bool navmeshCarver, const bool solid, const uint32_t collisionGroup) {
|
||||
if (navmeshCarver) return 0;
|
||||
if (!solid || collisionGroup == 0) return std::nullopt;
|
||||
if (dpCollisionFilter::ShouldCollide(collisionGroup, ENEMY_GROUP) && !dpCollisionFilter::ShouldCollide(collisionGroup, PLAYER_GROUP)) return collisionGroup;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<float> dpMovementBlockers::FirstHit(const std::span<const dpMovementBlocker> blockers, const NiPoint3& a, const NiPoint3& b, const uint32_t moverFilter) {
|
||||
const NiPoint3 lift{ 0.0f, STEP_HEIGHT, 0.0f };
|
||||
const auto from = a + lift;
|
||||
const auto to = b + lift;
|
||||
|
||||
std::optional<float> first;
|
||||
for (const auto& blocker : blockers) {
|
||||
if (!blocker.entity || blocker.entity->GetShape() == nullptr) continue;
|
||||
if (!dpCollisionFilter::ShouldCollide(blocker.filter, moverFilter)) continue;
|
||||
|
||||
const auto* const box = dynamic_cast<const dpShapeBox*>(blocker.entity->GetShape());
|
||||
if (!box) continue;
|
||||
|
||||
// Cheap reject against the box's axis aligned bounds first
|
||||
if (std::max(from.x, to.x) < box->m_MinX || std::min(from.x, to.x) > box->m_MaxX) continue;
|
||||
if (std::max(from.z, to.z) < box->m_MinZ || std::min(from.z, to.z) > box->m_MaxZ) continue;
|
||||
if (std::max(from.y, to.y) < box->m_MinY || std::min(from.y, to.y) > box->m_MaxY) continue;
|
||||
|
||||
const auto hit = box->SegmentEntry(from, to);
|
||||
if (hit && (!first || *hit < *first)) first = hit;
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
std::vector<NiPoint3> dpMovementBlockers::ClampPath(const std::span<const dpMovementBlocker> blockers, const NiPoint3& start, std::vector<NiPoint3> path, const uint32_t moverFilter) {
|
||||
if (blockers.empty()) return path;
|
||||
|
||||
auto previous = start;
|
||||
for (size_t i = 0; i < path.size(); i++) {
|
||||
const auto hit = FirstHit(blockers, previous, path[i], moverFilter);
|
||||
if (!hit) {
|
||||
previous = path[i];
|
||||
continue;
|
||||
}
|
||||
|
||||
const auto delta = path[i] - previous;
|
||||
const auto length = delta.Length();
|
||||
const auto keep = std::max(0.0f, *hit * length - STOP_DISTANCE);
|
||||
path.resize(i);
|
||||
if (length > 0.0f && keep > 0.01f) path.push_back(previous + delta * (keep / length));
|
||||
return path;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
52
dPhysics/dpMovementBlockers.h
Normal file
52
dPhysics/dpMovementBlockers.h
Normal file
@@ -0,0 +1,52 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <span>
|
||||
#include <vector>
|
||||
|
||||
#include "NiPoint3.h"
|
||||
|
||||
class dpEntity;
|
||||
|
||||
/**
|
||||
* Walls the characters the server walks (enemies, NPCs, pets on MovementAI) cannot cross. The server moves them along
|
||||
* navmesh paths without collision, so a wall that should stop them only does if their paths stop at it:
|
||||
* - Objects that carve the AI navmesh (navmesh_carver in their level config, read by the client's
|
||||
* LWOBasePhysComponent::LoadConfigData 0x00c495c9 next to add_to_navmesh and carver_only): nothing walked by the
|
||||
* server may go where they are. Many are carver_only too, which the client never loads, as the walls around the
|
||||
* Avant Gardens Sentinel camp.
|
||||
* - Solid objects whose collision group touches the movers but not players (e.g. group 18, "FV - Enemy Blocking
|
||||
* Volume" and the Nimbus Station pet ranch's "PR - Pet Blocker"): the client lets players through them, so they are
|
||||
* only there to stop NPCs.
|
||||
*/
|
||||
struct dpMovementBlocker {
|
||||
const dpEntity* entity{};
|
||||
// The collision filter it blocks with (see dpCollisionFilter): 0 blocks every mover
|
||||
uint32_t filter{};
|
||||
};
|
||||
|
||||
namespace dpMovementBlockers {
|
||||
// Paths are tested this far above the ground a mover walks on, so a wall sitting on uneven ground still catches them
|
||||
constexpr float STEP_HEIGHT = 1.0f;
|
||||
// How far before a wall a stopped path ends
|
||||
constexpr float STOP_DISTANCE = 1.0f;
|
||||
|
||||
/**
|
||||
* The filter a physics object blocks movers with, or none when it doesn't block them: 0 for a navmesh carver,
|
||||
* its group for a solid object only enemies (not players) collide with.
|
||||
*/
|
||||
[[nodiscard]] std::optional<uint32_t> BlockingFilter(bool navmeshCarver, bool solid, uint32_t collisionGroup);
|
||||
|
||||
/**
|
||||
* Where a mover with the given collision filter first walks into one of the blockers, going along the segment from
|
||||
* a to b, as a fraction of the way (0 to 1). Only boxes block; a mover that starts inside one can walk out.
|
||||
*/
|
||||
[[nodiscard]] std::optional<float> FirstHit(std::span<const dpMovementBlocker> blockers, const NiPoint3& a, const NiPoint3& b, uint32_t moverFilter);
|
||||
|
||||
/**
|
||||
* A path (the points after start) cut where it first walks into a blocker: it ends STOP_DISTANCE short of the wall.
|
||||
* Returns the path unchanged when it crosses none.
|
||||
*/
|
||||
[[nodiscard]] std::vector<NiPoint3> ClampPath(std::span<const dpMovementBlocker> blockers, const NiPoint3& start, std::vector<NiPoint3> path, uint32_t moverFilter);
|
||||
};
|
||||
@@ -8,6 +8,7 @@
|
||||
#include "NiQuaternion.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <iostream>
|
||||
|
||||
dpShapeBox::dpShapeBox(dpEntity* parentEntity, float width, float height, float depth) :
|
||||
@@ -108,6 +109,37 @@ float dpShapeBox::SquaredDistanceTo(const NiPoint3& point) const {
|
||||
return dX * dX + dY * dY + dZ * dZ;
|
||||
}
|
||||
|
||||
std::optional<float> dpShapeBox::SegmentEntry(const NiPoint3& a, const NiPoint3& b) const {
|
||||
// Into the box's frame (origin the middle of its bottom face), then clip the segment against each pair of faces
|
||||
const auto inverse = glm::conjugate(m_Orientation);
|
||||
const auto localA = (a - m_Origin).RotateByQuaternion(inverse);
|
||||
const auto localB = (b - m_Origin).RotateByQuaternion(inverse);
|
||||
|
||||
const float start[3]{ localA.x, localA.y, localA.z };
|
||||
const float delta[3]{ localB.x - localA.x, localB.y - localA.y, localB.z - localA.z };
|
||||
const float min[3]{ -m_Width, 0.0f, -m_Depth };
|
||||
const float max[3]{ m_Width, m_Height * 2.0f, m_Depth };
|
||||
|
||||
bool startsInside = true;
|
||||
float enter = 0.0f;
|
||||
float exit = 1.0f;
|
||||
for (int axis = 0; axis < 3; axis++) {
|
||||
if (start[axis] < min[axis] || start[axis] > max[axis]) startsInside = false;
|
||||
if (std::abs(delta[axis]) < 1e-6f) {
|
||||
if (start[axis] < min[axis] || start[axis] > max[axis]) return std::nullopt;
|
||||
continue;
|
||||
}
|
||||
float tNear = (min[axis] - start[axis]) / delta[axis];
|
||||
float tFar = (max[axis] - start[axis]) / delta[axis];
|
||||
if (tNear > tFar) std::swap(tNear, tFar);
|
||||
enter = std::max(enter, tNear);
|
||||
exit = std::min(exit, tFar);
|
||||
if (enter > exit) return std::nullopt;
|
||||
}
|
||||
if (startsInside) return std::nullopt;
|
||||
return enter;
|
||||
}
|
||||
|
||||
void dpShapeBox::InitVertices() {
|
||||
//The four top verts
|
||||
m_TopMinLeft = NiPoint3(GetMinWidth(), GetTop(), GetMinDepth());
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
#include "dpShapeBase.h"
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
#include "NiPoint3.h"
|
||||
#include "NiQuaternion.h"
|
||||
@@ -39,6 +40,13 @@ public:
|
||||
*/
|
||||
float SquaredDistanceTo(const NiPoint3& point) const;
|
||||
|
||||
/**
|
||||
* Where the segment from a to b first goes into the box, as a fraction of the way from a to b (0 to 1), tested
|
||||
* against the box's real (rotated) shape. Empty when the segment misses the box, or starts inside it (so
|
||||
* something already in the box can always leave it).
|
||||
*/
|
||||
std::optional<float> SegmentEntry(const NiPoint3& a, const NiPoint3& b) const;
|
||||
|
||||
void InitVertices();
|
||||
|
||||
void SetPosition(const NiPoint3& position);
|
||||
|
||||
@@ -22,6 +22,8 @@ namespace {
|
||||
std::vector<dpEntity*> m_StaticEntities;
|
||||
std::vector<dpEntity*> m_DynamicEntites;
|
||||
bool phys_spatial_partitioning = true;
|
||||
|
||||
std::vector<dpMovementBlocker> m_MovementBlockers;
|
||||
};
|
||||
|
||||
void dpWorld::Initialize(unsigned int zoneID, bool generateNewNavMesh) {
|
||||
@@ -84,6 +86,8 @@ void dpWorld::Shutdown() {
|
||||
delete m_NavMesh;
|
||||
m_NavMesh = nullptr;
|
||||
}
|
||||
|
||||
m_MovementBlockers.clear();
|
||||
}
|
||||
|
||||
bool dpWorld::IsLoaded() {
|
||||
@@ -187,3 +191,20 @@ bool dpWorld::ShouldUseSP(uint32_t zoneID) {
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void dpWorld::AddMovementBlocker(const dpEntity* entity, const uint32_t filter) {
|
||||
if (!entity) return;
|
||||
m_MovementBlockers.push_back({ entity, filter });
|
||||
}
|
||||
|
||||
void dpWorld::RemoveMovementBlocker(const dpEntity* entity) {
|
||||
std::erase_if(m_MovementBlockers, [entity](const dpMovementBlocker& blocker) { return blocker.entity == entity; });
|
||||
}
|
||||
|
||||
std::span<const dpMovementBlocker> dpWorld::GetMovementBlockers() {
|
||||
return m_MovementBlockers;
|
||||
}
|
||||
|
||||
std::vector<NiPoint3> dpWorld::ClampPath(const NiPoint3& start, std::vector<NiPoint3> path, const uint32_t moverFilter) {
|
||||
return dpMovementBlockers::ClampPath(m_MovementBlockers, start, std::move(path), moverFilter);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <span>
|
||||
#include <vector>
|
||||
|
||||
#include "dpMovementBlockers.h"
|
||||
|
||||
class dNavMesh;
|
||||
class dpEntity;
|
||||
@@ -24,4 +28,15 @@ namespace dpWorld {
|
||||
void DetachEntity(dpEntity* entity);
|
||||
|
||||
dNavMesh* GetNavMesh();
|
||||
|
||||
/**
|
||||
* A wall the server's movers (MovementAI) cannot walk through (see dpMovementBlocker). The entity stays owned by
|
||||
* the caller, who removes it before deleting it; it is not stepped with the world.
|
||||
*/
|
||||
void AddMovementBlocker(const dpEntity* entity, uint32_t filter);
|
||||
void RemoveMovementBlocker(const dpEntity* entity);
|
||||
std::span<const dpMovementBlocker> GetMovementBlockers();
|
||||
|
||||
// A mover's path cut where it first walks into a movement blocker (dpMovementBlockers::ClampPath)
|
||||
std::vector<NiPoint3> ClampPath(const NiPoint3& start, std::vector<NiPoint3> path, uint32_t moverFilter);
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ set(DGAMETEST_SOURCES
|
||||
"ContrabandTests.cpp"
|
||||
"KnockbackTests.cpp"
|
||||
"CollisionFilterTests.cpp"
|
||||
"MovementBlockerTests.cpp"
|
||||
"ItemSetPassiveAbilityTests.cpp"
|
||||
"PowerUpStatisticTests.cpp"
|
||||
"PlayerStatisticTests.cpp"
|
||||
|
||||
110
tests/dGameTests/MovementBlockerTests.cpp
Normal file
110
tests/dGameTests/MovementBlockerTests.cpp
Normal file
@@ -0,0 +1,110 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include "dpEntity.h"
|
||||
#include "dpMovementBlockers.h"
|
||||
#include "dpShapeBox.h"
|
||||
#include "NiQuaternion.h"
|
||||
|
||||
using namespace dpMovementBlockers;
|
||||
|
||||
namespace {
|
||||
constexpr uint32_t PLAYER = 10;
|
||||
constexpr uint32_t ENEMY = 12;
|
||||
|
||||
// A wall placed the way the physics components place one: scaled, turned, then moved
|
||||
std::unique_ptr<dpEntity> Wall(const NiPoint3& position, const NiQuaternion& rotation, float scale, float width = 10.0f, float height = 5.0f, float depth = 1.0f) {
|
||||
auto wall = std::make_unique<dpEntity>(1, width, height, depth);
|
||||
wall->SetScale(scale);
|
||||
wall->SetRotation(rotation);
|
||||
wall->SetPosition(position);
|
||||
return wall;
|
||||
}
|
||||
|
||||
// Turned 90 degrees about Y, as the Sentinel camp walls: its width runs along world z
|
||||
NiQuaternion Quarter() { return NiQuaternion(0.7071068f, 0.0f, -0.7071068f, 0.0f); }
|
||||
}
|
||||
|
||||
TEST(MovementBlockerTests, BlockingFilterFromData) {
|
||||
// Navmesh carvers stop every mover, whatever their group
|
||||
EXPECT_EQ(BlockingFilter(true, false, 7), 0u);
|
||||
EXPECT_EQ(BlockingFilter(true, true, 1), 0u);
|
||||
// Solid group 18 volumes (FV - Enemy Blocking Volume, PR - Pet Blocker) touch enemies and not players
|
||||
EXPECT_EQ(BlockingFilter(false, true, 18), 18u);
|
||||
// A phantom group 18 volume ("Clear threat list Trigger Wall") is a trigger, not a wall
|
||||
EXPECT_FALSE(BlockingFilter(false, false, 18).has_value());
|
||||
// Ordinary solid objects players collide with are left to the navmesh
|
||||
EXPECT_FALSE(BlockingFilter(false, true, 1).has_value());
|
||||
EXPECT_FALSE(BlockingFilter(false, true, 0).has_value());
|
||||
}
|
||||
|
||||
TEST(MovementBlockerTests, SegmentEntryOnTurnedWall) {
|
||||
// 10 x 5 x 1 scaled 2.5 at x = 134: 25 long along z, 2.5 thick along x, 12.5 tall
|
||||
const auto wall = Wall({ 134.0f, 375.0f, -200.0f }, Quarter(), 2.5f);
|
||||
const auto* box = dynamic_cast<const dpShapeBox*>(wall->GetShape());
|
||||
ASSERT_NE(box, nullptr);
|
||||
|
||||
// Straight across it, from x = 124 to 144: enters at its near face, x = 132.75
|
||||
const auto hit = box->SegmentEntry({ 124.0f, 376.0f, -200.0f }, { 144.0f, 376.0f, -200.0f });
|
||||
ASSERT_TRUE(hit.has_value());
|
||||
EXPECT_NEAR(*hit, (132.75f - 124.0f) / 20.0f, 1e-3f);
|
||||
|
||||
// Past its end (z = -215 is beyond -200 +- 12.5)
|
||||
EXPECT_FALSE(box->SegmentEntry({ 124.0f, 376.0f, -215.0f }, { 144.0f, 376.0f, -215.0f }).has_value());
|
||||
// Along it without touching
|
||||
EXPECT_FALSE(box->SegmentEntry({ 130.0f, 376.0f, -210.0f }, { 130.0f, 376.0f, -190.0f }).has_value());
|
||||
// Over it
|
||||
EXPECT_FALSE(box->SegmentEntry({ 124.0f, 390.0f, -200.0f }, { 144.0f, 390.0f, -200.0f }).has_value());
|
||||
// Starting inside it: free to walk out
|
||||
EXPECT_FALSE(box->SegmentEntry({ 134.0f, 376.0f, -200.0f }, { 144.0f, 376.0f, -200.0f }).has_value());
|
||||
}
|
||||
|
||||
TEST(MovementBlockerTests, FilterDecidesWhoIsBlocked) {
|
||||
const auto wall = Wall({ 0.0f, 0.0f, 0.0f }, QuatUtils::IDENTITY, 1.0f);
|
||||
const std::vector<dpMovementBlocker> enemyOnly{ { wall.get(), 18 } };
|
||||
const NiPoint3 a{ 0.0f, 0.0f, -5.0f };
|
||||
const NiPoint3 b{ 0.0f, 0.0f, 5.0f };
|
||||
|
||||
EXPECT_TRUE(FirstHit(enemyOnly, a, b, ENEMY).has_value());
|
||||
EXPECT_FALSE(FirstHit(enemyOnly, a, b, PLAYER).has_value());
|
||||
|
||||
const std::vector<dpMovementBlocker> carver{ { wall.get(), 0 } };
|
||||
EXPECT_TRUE(FirstHit(carver, a, b, ENEMY).has_value());
|
||||
EXPECT_TRUE(FirstHit(carver, a, b, PLAYER).has_value());
|
||||
}
|
||||
|
||||
TEST(MovementBlockerTests, ClampPathStopsBeforeTheWall) {
|
||||
// A wall across z = 0 (x from -5 to 5, 1 thick), and an enemy walking from z = -10 to z = 10 in two legs
|
||||
const auto wall = Wall({ 0.0f, 0.0f, 0.0f }, QuatUtils::IDENTITY, 1.0f);
|
||||
const std::vector<dpMovementBlocker> blockers{ { wall.get(), 0 } };
|
||||
const NiPoint3 start{ 0.0f, 0.0f, -10.0f };
|
||||
|
||||
const auto path = ClampPath(blockers, start, { { 0.0f, 0.0f, -8.0f }, { 0.0f, 0.0f, 10.0f } }, ENEMY);
|
||||
ASSERT_EQ(path.size(), 2u);
|
||||
EXPECT_FLOAT_EQ(path[0].z, -8.0f);
|
||||
// The wall's near face is z = -0.5; the path ends STOP_DISTANCE before it
|
||||
EXPECT_NEAR(path[1].z, -0.5f - STOP_DISTANCE, 1e-3f);
|
||||
|
||||
// Around the end of the wall is fine
|
||||
const auto around = ClampPath(blockers, start, { { 8.0f, 0.0f, -2.0f }, { 8.0f, 0.0f, 2.0f }, { 0.0f, 0.0f, 10.0f } }, ENEMY);
|
||||
EXPECT_EQ(around.size(), 3u);
|
||||
|
||||
// Right up against the wall: nothing left to walk
|
||||
const auto against = ClampPath(blockers, { 0.0f, 0.0f, -1.2f }, { { 0.0f, 0.0f, 10.0f } }, ENEMY);
|
||||
EXPECT_TRUE(against.empty());
|
||||
|
||||
// No blockers, no change
|
||||
const auto free = ClampPath({}, start, { { 0.0f, 0.0f, 10.0f } }, ENEMY);
|
||||
EXPECT_EQ(free.size(), 1u);
|
||||
}
|
||||
|
||||
TEST(MovementBlockerTests, ClearThreatWallIsTheRealSize) {
|
||||
// test\POI_trigger_wall.hkx: 1 x 12.98 x 20.45, placed at 2.44 on the Block Yard property, turned a quarter
|
||||
const auto wall = Wall({ -21.6f, 456.7f, -42.3f }, NiQuaternion(0.706f, 0.0f, -0.708f, 0.0f), 2.44f, 1.0f, 12.9755f, 20.45f);
|
||||
const auto* box = dynamic_cast<const dpShapeBox*>(wall->GetShape());
|
||||
ASSERT_NE(box, nullptr);
|
||||
// About 50 long along x, so an enemy walking along z 20 from its middle still walks into it
|
||||
EXPECT_NEAR(box->m_MaxX - box->m_MinX, 20.45f * 2.44f, 0.5f);
|
||||
EXPECT_TRUE(box->SegmentEntry({ -1.6f, 458.0f, -52.3f }, { -1.6f, 458.0f, -32.3f }).has_value());
|
||||
}
|
||||
Reference in New Issue
Block a user