diff --git a/dPhysics/CMakeLists.txt b/dPhysics/CMakeLists.txt index 55f2c22d7..22ae984c4 100644 --- a/dPhysics/CMakeLists.txt +++ b/dPhysics/CMakeLists.txt @@ -3,6 +3,7 @@ set(DPHYSICS_SOURCES "dpCollisionChecks.cpp" "dpCollisionFilter.cpp" "dpGrid.cpp" "dpKnockback.cpp" + "dpMovementBlockers.cpp" "dpShapeBase.cpp" "dpShapeBox.cpp" "dpShapeSphere.cpp" diff --git a/dPhysics/dpEntity.h b/dPhysics/dpEntity.h index 81db85d46..d727f1c56 100644 --- a/dPhysics/dpEntity.h +++ b/dPhysics/dpEntity.h @@ -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; } diff --git a/dPhysics/dpMovementBlockers.cpp b/dPhysics/dpMovementBlockers.cpp new file mode 100644 index 000000000..89fc97f72 --- /dev/null +++ b/dPhysics/dpMovementBlockers.cpp @@ -0,0 +1,64 @@ +#include "dpMovementBlockers.h" + +#include + +#include "dpCollisionFilter.h" +#include "dpEntity.h" +#include "dpShapeBox.h" + +namespace { + constexpr uint32_t PLAYER_GROUP = 10; + constexpr uint32_t ENEMY_GROUP = 12; +} + +std::optional 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 dpMovementBlockers::FirstHit(const std::span 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 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(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 dpMovementBlockers::ClampPath(const std::span blockers, const NiPoint3& start, std::vector 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; +} diff --git a/dPhysics/dpMovementBlockers.h b/dPhysics/dpMovementBlockers.h new file mode 100644 index 000000000..29c75a5aa --- /dev/null +++ b/dPhysics/dpMovementBlockers.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include +#include +#include + +#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 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 FirstHit(std::span 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 ClampPath(std::span blockers, const NiPoint3& start, std::vector path, uint32_t moverFilter); +}; diff --git a/dPhysics/dpShapeBox.cpp b/dPhysics/dpShapeBox.cpp index 8c1fcdcea..5eeb5fb4e 100644 --- a/dPhysics/dpShapeBox.cpp +++ b/dPhysics/dpShapeBox.cpp @@ -8,6 +8,7 @@ #include "NiQuaternion.h" #include +#include #include 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 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()); diff --git a/dPhysics/dpShapeBox.h b/dPhysics/dpShapeBox.h index 3619e75eb..8da0b6508 100644 --- a/dPhysics/dpShapeBox.h +++ b/dPhysics/dpShapeBox.h @@ -1,5 +1,6 @@ #pragma once #include "dpShapeBase.h" +#include #include #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 SegmentEntry(const NiPoint3& a, const NiPoint3& b) const; + void InitVertices(); void SetPosition(const NiPoint3& position); diff --git a/dPhysics/dpWorld.cpp b/dPhysics/dpWorld.cpp index df2ea072b..1aad9f0f9 100644 --- a/dPhysics/dpWorld.cpp +++ b/dPhysics/dpWorld.cpp @@ -22,6 +22,8 @@ namespace { std::vector m_StaticEntities; std::vector m_DynamicEntites; bool phys_spatial_partitioning = true; + + std::vector 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 dpWorld::GetMovementBlockers() { + return m_MovementBlockers; +} + +std::vector dpWorld::ClampPath(const NiPoint3& start, std::vector path, const uint32_t moverFilter) { + return dpMovementBlockers::ClampPath(m_MovementBlockers, start, std::move(path), moverFilter); +} diff --git a/dPhysics/dpWorld.h b/dPhysics/dpWorld.h index 6514ed4b5..f14931c85 100644 --- a/dPhysics/dpWorld.h +++ b/dPhysics/dpWorld.h @@ -1,6 +1,10 @@ #pragma once #include +#include +#include + +#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 GetMovementBlockers(); + + // A mover's path cut where it first walks into a movement blocker (dpMovementBlockers::ClampPath) + std::vector ClampPath(const NiPoint3& start, std::vector path, uint32_t moverFilter); }; diff --git a/tests/dGameTests/CMakeLists.txt b/tests/dGameTests/CMakeLists.txt index 17c6614af..4219a6b87 100644 --- a/tests/dGameTests/CMakeLists.txt +++ b/tests/dGameTests/CMakeLists.txt @@ -10,6 +10,7 @@ set(DGAMETEST_SOURCES "ContrabandTests.cpp" "KnockbackTests.cpp" "CollisionFilterTests.cpp" + "MovementBlockerTests.cpp" "ItemSetPassiveAbilityTests.cpp" "PowerUpStatisticTests.cpp" "PlayerStatisticTests.cpp" diff --git a/tests/dGameTests/MovementBlockerTests.cpp b/tests/dGameTests/MovementBlockerTests.cpp new file mode 100644 index 000000000..51664ae02 --- /dev/null +++ b/tests/dGameTests/MovementBlockerTests.cpp @@ -0,0 +1,110 @@ +#include + +#include + +#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 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(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(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 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 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 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(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()); +}