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:
Aaron Kimbrell
2026-09-29 17:37:37 -05:00
parent 69ee6b0bc6
commit 425b913848
10 changed files with 305 additions and 0 deletions

View File

@@ -3,6 +3,7 @@ set(DPHYSICS_SOURCES "dpCollisionChecks.cpp"
"dpCollisionFilter.cpp"
"dpGrid.cpp"
"dpKnockback.cpp"
"dpMovementBlockers.cpp"
"dpShapeBase.cpp"
"dpShapeBox.cpp"
"dpShapeSphere.cpp"

View File

@@ -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; }

View 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;
}

View 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);
};

View File

@@ -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());

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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);
};

View File

@@ -10,6 +10,7 @@ set(DGAMETEST_SOURCES
"ContrabandTests.cpp"
"KnockbackTests.cpp"
"CollisionFilterTests.cpp"
"MovementBlockerTests.cpp"
"ItemSetPassiveAbilityTests.cpp"
"PowerUpStatisticTests.cpp"
"PlayerStatisticTests.cpp"

View 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());
}