From 5d3c2e6f9a20c93a9e10e8c254926d70db5e9187 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Sat, 26 Sep 2026 23:57:50 -0500 Subject: [PATCH] fix: filter physics volumes like the client's collision groups Volumes on the server touched far more than in the client: - An enemy's body in the physics world was a sphere the size of its aggro radius, so trigger and damage volumes caught enemies from far away (Cavalry Hill enemies taking damage on spawn). It is now the enemy's own radius and collision group from its physics component. - Trigger volumes ignored their collision group and caught everything. dpEntity now filters with the client's collision filter (PeCollisionFilter, 1.10.64 0x00fb6940, group table from 0x00fcf9a0): POI walls ignore enemies, threat clearing walls ignore players, and so on. The aggro sensor keeps seeing only players. - Rotated boxes were tested as the axis aligned box around them, which for a turned wall covers a big square (the AG survival boundary). Sphere and point tests now use the box's own axes. Proximity monitors take an optional collision group like live's SetProximityRadius; the AM shield generators use live's (10 finds enemies, 1 finds players). Fixes #1127 Refs #1971 Co-Authored-By: Claude Opus 5.5 --- dCommon/dEnums/dpCollisionGroups.h | 16 ---- dGame/Entity.cpp | 4 +- dGame/Entity.h | 2 +- dGame/dComponents/BaseCombatAIComponent.cpp | 24 +++-- .../ControllablePhysicsComponent.cpp | 3 +- dGame/dComponents/PhysicsComponent.cpp | 7 +- .../dComponents/ProximityMonitorComponent.cpp | 3 +- dGame/dComponents/ProximityMonitorComponent.h | 6 +- dPhysics/CMakeLists.txt | 1 + dPhysics/dpCollisionChecks.cpp | 11 +-- dPhysics/dpCollisionFilter.cpp | 89 +++++++++++++++++++ dPhysics/dpCollisionFilter.h | 31 +++++++ dPhysics/dpEntity.cpp | 12 ++- dPhysics/dpEntity.h | 9 +- dPhysics/dpShapeBox.cpp | 23 ++--- dPhysics/dpShapeBox.h | 10 +++ .../02_server/Map/AM/AmShieldGenerator.cpp | 5 +- .../Map/AM/AmShieldGeneratorQuickbuild.cpp | 5 +- tests/dGameTests/CMakeLists.txt | 1 + tests/dGameTests/CollisionFilterTests.cpp | 79 ++++++++++++++++ 20 files changed, 277 insertions(+), 64 deletions(-) delete mode 100644 dCommon/dEnums/dpCollisionGroups.h create mode 100644 dPhysics/dpCollisionFilter.cpp create mode 100644 dPhysics/dpCollisionFilter.h create mode 100644 tests/dGameTests/CollisionFilterTests.cpp diff --git a/dCommon/dEnums/dpCollisionGroups.h b/dCommon/dEnums/dpCollisionGroups.h deleted file mode 100644 index ad9807b98..000000000 --- a/dCommon/dEnums/dpCollisionGroups.h +++ /dev/null @@ -1,16 +0,0 @@ -#pragma once - -#include - -/* - * Collision Groups - */ - -enum eCollisionGroup : uint8_t -{ - COLLISION_GROUP_ALL = 0 << 0, - COLLISION_GROUP_NEUTRAL = 1 << 0, - COLLISION_GROUP_FRIENDLY = 1 << 1, - COLLISION_GROUP_ENEMY = 1 << 2, - COLLISION_GROUP_DYNAMIC = 1 << 3, -}; diff --git a/dGame/Entity.cpp b/dGame/Entity.cpp index 2a9403954..8707d5285 100644 --- a/dGame/Entity.cpp +++ b/dGame/Entity.cpp @@ -923,10 +923,10 @@ void Entity::Unsubscribe(LWOOBJID scriptObjId, const std::string& notificationNa } } -void Entity::SetProximityRadius(float proxRadius, std::string name) { +void Entity::SetProximityRadius(float proxRadius, std::string name, uint32_t collisionGroup) { auto* proxMon = GetComponent(); if (!proxMon) proxMon = AddComponent(-1); - proxMon->SetProximityRadius(proxRadius, name); + proxMon->SetProximityRadius(proxRadius, name, collisionGroup); } void Entity::SetProximityRadius(dpEntity* entity, std::string name) { diff --git a/dGame/Entity.h b/dGame/Entity.h index 4fc00cbf5..275aea2ce 100644 --- a/dGame/Entity.h +++ b/dGame/Entity.h @@ -196,7 +196,7 @@ public: void Subscribe(LWOOBJID scriptObjId, CppScripts::Script* scriptToAdd, const std::string& notificationName); void Unsubscribe(LWOOBJID scriptObjId, const std::string& notificationName); - void SetProximityRadius(float proxRadius, std::string name); + void SetProximityRadius(float proxRadius, std::string name, uint32_t collisionGroup = 0); void SetProximityRadius(dpEntity* entity, std::string name); void AddChild(Entity* child); diff --git a/dGame/dComponents/BaseCombatAIComponent.cpp b/dGame/dComponents/BaseCombatAIComponent.cpp index a86fb9b5d..b1719478d 100644 --- a/dGame/dComponents/BaseCombatAIComponent.cpp +++ b/dGame/dComponents/BaseCombatAIComponent.cpp @@ -30,6 +30,15 @@ #include "CDPhysicsComponentTable.h" #include "dNavMesh.h" #include "Amf3.h" +#include "dpCollisionFilter.h" + +namespace { + // The collision groups of players and enemies in PhysicsComponent + constexpr uint32_t PLAYER_COLLISION_GROUP = 10; + constexpr uint32_t ENEMY_COLLISION_GROUP = 12; + // standard_enemy.hkx's radius + constexpr float DEFAULT_BODY_RADIUS = 1.7f; +} BaseCombatAIComponent::BaseCombatAIComponent(Entity* parent, const int32_t componentID) : Component(parent, componentID) { RegisterMsg(&BaseCombatAIComponent::MsgGetObjectReportInfo); @@ -105,7 +114,11 @@ BaseCombatAIComponent::BaseCombatAIComponent(Entity* parent, const int32_t compo * Add physics */ - int32_t collisionGroup = (COLLISION_GROUP_DYNAMIC | COLLISION_GROUP_ENEMY); + // The enemy's own body, which other volumes (triggers, damage volumes, proximity monitors) see: its group and + // radius from its physics component, like its character controller in the client. It used to be a sphere of + // the aggro radius, so volumes caught enemies from far away (e.g. the Cavalry Hill damage volumes, #1127). + uint32_t collisionGroup = ENEMY_COLLISION_GROUP; + float bodyRadius = DEFAULT_BODY_RADIUS; CDComponentsRegistryTable* componentRegistryTable = CDClientManager::GetTable(); const auto controllablePhysicsID = componentRegistryTable->GetByIDAndType(parent->GetLOT(), eReplicaComponentType::CONTROLLABLE_PHYSICS); @@ -115,15 +128,16 @@ BaseCombatAIComponent::BaseCombatAIComponent(Entity* parent, const int32_t compo if (physicsComponentTable != nullptr) { auto* info = physicsComponentTable->GetByID(controllablePhysicsID); if (info != nullptr) { - collisionGroup = info->bStatic ? COLLISION_GROUP_NEUTRAL : info->collisionGroup; + collisionGroup = static_cast(info->collisionGroup); + if (info->playerRadius > 0.0f) bodyRadius = info->playerRadius; } } - //Create a phantom physics volume so we can detect when we're aggro'd. + // The aggro sensor only needs to see players, as before; targets are picked by faction afterwards m_dpEntity = new dpEntity(m_Parent->GetObjectID(), m_AggroRadius); - m_dpEntityEnemy = new dpEntity(m_Parent->GetObjectID(), m_AggroRadius, false); + m_dpEntityEnemy = new dpEntity(m_Parent->GetObjectID(), bodyRadius, false); - m_dpEntity->SetCollisionGroup(collisionGroup); + m_dpEntity->SetCollisionGroup(dpCollisionFilter::GROUP_MASK | (1u << (PLAYER_COLLISION_GROUP - 1))); m_dpEntityEnemy->SetCollisionGroup(collisionGroup); m_dpEntity->SetPosition(m_Parent->GetPosition()); diff --git a/dGame/dComponents/ControllablePhysicsComponent.cpp b/dGame/dComponents/ControllablePhysicsComponent.cpp index 99ae1b663..a4e5fdc12 100644 --- a/dGame/dComponents/ControllablePhysicsComponent.cpp +++ b/dGame/dComponents/ControllablePhysicsComponent.cpp @@ -58,7 +58,8 @@ ControllablePhysicsComponent::ControllablePhysicsComponent(Entity* entity, const float radius = 1.5f; m_dpEntity = new dpEntity(m_Parent->GetObjectID(), radius, false); - m_dpEntity->SetCollisionGroup(COLLISION_GROUP_DYNAMIC | COLLISION_GROUP_FRIENDLY); + // The player's group from its physics component (10), which trigger volumes filter on + m_dpEntity->SetCollisionGroup(static_cast(m_CollisionGroup)); dpWorld::AddEntity(m_dpEntity); } } diff --git a/dGame/dComponents/PhysicsComponent.cpp b/dGame/dComponents/PhysicsComponent.cpp index 0d3a302b2..39d67e4ae 100644 --- a/dGame/dComponents/PhysicsComponent.cpp +++ b/dGame/dComponents/PhysicsComponent.cpp @@ -103,6 +103,8 @@ dpEntity* PhysicsComponent::CreatePhysicsEntity(eReplicaComponentType type) { //add fallback cube: toReturn = new dpEntity(m_Parent->GetObjectID(), 2.0f, 2.0f, 2.0f); } + // Only touch what the client lets this group touch (e.g. POI walls ignore enemies, threat clearing walls ignore players) + toReturn->SetCollisionGroup(static_cast(m_CollisionGroup)); return toReturn; } @@ -174,7 +176,10 @@ dpEntity* PhysicsComponent::CreatePhysicsLnv(const float scale, const eReplicaCo } } - if (toReturn) dpWorld::AddEntity(toReturn); + if (toReturn) { + toReturn->SetCollisionGroup(static_cast(m_CollisionGroup)); + dpWorld::AddEntity(toReturn); + } return toReturn; } diff --git a/dGame/dComponents/ProximityMonitorComponent.cpp b/dGame/dComponents/ProximityMonitorComponent.cpp index ca48a429c..91b3ad738 100644 --- a/dGame/dComponents/ProximityMonitorComponent.cpp +++ b/dGame/dComponents/ProximityMonitorComponent.cpp @@ -27,8 +27,9 @@ ProximityMonitorComponent::~ProximityMonitorComponent() { m_ProximitiesData.clear(); } -void ProximityMonitorComponent::SetProximityRadius(float proxRadius, const std::string& name) { +void ProximityMonitorComponent::SetProximityRadius(float proxRadius, const std::string& name, const uint32_t collisionGroup) { dpEntity* en = new dpEntity(m_Parent->GetObjectID(), proxRadius); + en->SetCollisionGroup(collisionGroup); en->SetPosition(m_Parent->GetPosition()); dpWorld::AddEntity(en); diff --git a/dGame/dComponents/ProximityMonitorComponent.h b/dGame/dComponents/ProximityMonitorComponent.h index b4aa1f1a3..e9e7a0745 100644 --- a/dGame/dComponents/ProximityMonitorComponent.h +++ b/dGame/dComponents/ProximityMonitorComponent.h @@ -32,7 +32,11 @@ public: * @param proxRadius the radius to use for the physics entity we use to detect proximity * @param name the name of this check */ - void SetProximityRadius(float proxRadius, const std::string& name); + /** + * Adds a sphere that reports what enters and leaves it + * @param collisionGroup the collision group it filters on like the client's (see dpCollisionFilter), 0 for everything + */ + void SetProximityRadius(float proxRadius, const std::string& name, uint32_t collisionGroup = 0); /** * Creates an entry to check proximity for, given a name diff --git a/dPhysics/CMakeLists.txt b/dPhysics/CMakeLists.txt index 508bcd16d..55f2c22d7 100644 --- a/dPhysics/CMakeLists.txt +++ b/dPhysics/CMakeLists.txt @@ -1,5 +1,6 @@ set(DPHYSICS_SOURCES "dpCollisionChecks.cpp" "dpEntity.cpp" + "dpCollisionFilter.cpp" "dpGrid.cpp" "dpKnockback.cpp" "dpShapeBase.cpp" diff --git a/dPhysics/dpCollisionChecks.cpp b/dPhysics/dpCollisionChecks.cpp index c2efee905..be6c6fb8a 100644 --- a/dPhysics/dpCollisionChecks.cpp +++ b/dPhysics/dpCollisionChecks.cpp @@ -100,16 +100,7 @@ bool dpCollisionChecks::CheckSphereBox(dpEntity* a, dpEntity* b) { spherePos = a->GetPosition(); } - //Get closest point from the box to the sphere center by clamping - float x = std::max(box->m_MinX, std::min(spherePos.x, box->m_MaxX)); - float y = std::max(box->m_MinY, std::min(spherePos.y, box->m_MaxY)); - float z = std::max(box->m_MinZ, std::min(spherePos.z, box->m_MaxZ)); - - //Check the distance between that point & our sphere - float dX = x - spherePos.x; - float dY = y - spherePos.y; - float dZ = z - spherePos.z; - float distanceSquared = (dX * dX) + (dY * dY) + (dZ * dZ); + const float distanceSquared = box->SquaredDistanceTo(spherePos); const float radius = sphere->GetRadius(); return distanceSquared < radius* radius; diff --git a/dPhysics/dpCollisionFilter.cpp b/dPhysics/dpCollisionFilter.cpp new file mode 100644 index 000000000..d3109b73c --- /dev/null +++ b/dPhysics/dpCollisionFilter.cpp @@ -0,0 +1,89 @@ +#include "dpCollisionFilter.h" + +#include + +namespace { + using Table = std::array; + + // Group `group` touches every group whose bit is set in `mask`, and each of those touches it back + constexpr void Enable(Table& table, const uint32_t group, const uint32_t mask) { + table[group - 1] |= mask; + for (uint32_t other = 0; other < dpCollisionFilter::GROUP_COUNT; other++) { + if (mask & (1u << other)) table[other] |= 1u << (group - 1); + } + } + + // The client's calls, in its order + constexpr Table BuildTable() { + Table table{}; + Enable(table, 1, 0x96d9); + Enable(table, 2, 0x9400); + Enable(table, 10, 0x1cc9); + Enable(table, 3, 0x204d8); + Enable(table, 12, 0x24ed8); + Enable(table, 4, 0x1604); + Enable(table, 13, 0x164b); + Enable(table, 14, 0x1000); + Enable(table, 15, 0x246d9); + Enable(table, 24, 0xc000); + Enable(table, 5, 0x7c5); + Enable(table, 16, 0x4c3); + Enable(table, 7, 0x96d5); + Enable(table, 11, 0x9257); + Enable(table, 8, 0x8235); + Enable(table, 18, 0x4); + Enable(table, 19, 0x40449); + Enable(table, 20, 0x967d); + Enable(table, 22, 0x451); + Enable(table, 23, 0x401cd9); + Enable(table, 17, 0x400280); + Enable(table, 21, 0); + Enable(table, 25, 0); + return table; + } + + constexpr Table GROUP_TABLE = BuildTable(); + + // Not the same system: same group and the same system bits (and some set) never touch (0x00fb5720) + bool DifferentSystems(const uint32_t a, const uint32_t b) { + using namespace dpCollisionFilter; + const bool sameSystem = (a & GROUP_BITS) == (b & GROUP_BITS) + && ((a & SYSTEM_BITS) != 0 || (b & SYSTEM_BITS) != 0) + && (a & SYSTEM_BITS) == (b & SYSTEM_BITS); + return !sameSystem; + } + + // A group mask touches the other's group (0x00fb5780) + bool MaskHasGroup(const uint32_t mask, const uint32_t other) { + using namespace dpCollisionFilter; + const auto group = other & GROUP_BITS; + return other == 0 || (group > 0 && group <= 32 && (mask & (1u << (group - 1))) != 0); + } +} + +bool dpCollisionFilter::GroupsCollide(const uint32_t groupA, const uint32_t groupB) { + if (groupA == 0 || groupB == 0) return true; + if (groupA > GROUP_COUNT || groupB > GROUP_COUNT) return false; + return (GROUP_TABLE[groupA - 1] & (1u << (groupB - 1))) != 0; +} + +bool dpCollisionFilter::ShouldCollide(uint32_t filterA, uint32_t filterB) { + if ((filterA & PHANTOM_ONLY) || (filterB & PHANTOM_ONLY)) { + if ((filterA & PHANTOM_ONLY) && (filterB & PHANTOM_ONLY)) return false; + filterA &= ~PHANTOM_ONLY; + filterB &= ~PHANTOM_ONLY; + } + + if (filterA == 0 || filterB == 0) return true; + + const bool maskA = (filterA & GROUP_MASK) != 0; + const bool maskB = (filterB & GROUP_MASK) != 0; + if (!maskA && !maskB) { + const auto groupA = filterA & GROUP_BITS; + const auto groupB = filterB & GROUP_BITS; + return GroupsCollide(groupA, groupB) && DifferentSystems(filterA, filterB); + } + if (maskA && maskB) return false; + if (maskA) return MaskHasGroup(filterA, filterB) && DifferentSystems(filterA, filterB); + return MaskHasGroup(filterB, filterA) && DifferentSystems(filterB, filterA); +} diff --git a/dPhysics/dpCollisionFilter.h b/dPhysics/dpCollisionFilter.h new file mode 100644 index 000000000..d25295822 --- /dev/null +++ b/dPhysics/dpCollisionFilter.h @@ -0,0 +1,31 @@ +#ifndef DPCOLLISIONFILTER_H +#define DPCOLLISIONFILTER_H + +#include + +/** + * Which physics objects can touch, the way the client's Havok filter decides it (1.10.64: table built in + * PeCollisionFilter::SetupGroups 0x00fcf9a0, tested in PeCollisionFilter::IsCollisionEnabled 0x00fb6940). + * + * A filter value is a collision group (PhysicsComponent.collisionGroup, or the CollisionGroupID config) with + * optional flags in the high bits: + * - 0 touches everything. + * - PHANTOM_ONLY (0x40000000): two objects that both have it never touch; otherwise it is ignored. + * - GROUP_MASK (0x04000000): the low bits are a mask of the groups it touches (bit n = group n + 1) instead of a group. + * - Two objects with the same group and the same non zero high bits never touch (the same system's parts). + * Otherwise two groups touch when the client's group table says so. Players are group 10, enemies 12. + */ +namespace dpCollisionFilter { + constexpr uint32_t PHANTOM_ONLY = 0x40000000; + constexpr uint32_t GROUP_MASK = 0x04000000; + constexpr uint32_t GROUP_BITS = 0x07ffffff; + constexpr uint32_t SYSTEM_BITS = 0xf8000000; + constexpr uint32_t GROUP_COUNT = 26; + + [[nodiscard]] bool ShouldCollide(uint32_t filterA, uint32_t filterB); + + // Whether group a touches group b in the client's table (groups start at 1) + [[nodiscard]] bool GroupsCollide(uint32_t groupA, uint32_t groupB); +}; + +#endif //!DPCOLLISIONFILTER_H diff --git a/dPhysics/dpEntity.cpp b/dPhysics/dpEntity.cpp index 6368764f1..44cf92425 100644 --- a/dPhysics/dpEntity.cpp +++ b/dPhysics/dpEntity.cpp @@ -8,7 +8,7 @@ dpEntity::dpEntity(const LWOOBJID& objectID, dpShapeType shapeType, bool isStati m_IsStatic = isStatic; m_CollisionShape = nullptr; m_Scale = 1.0f; - m_CollisionGroup = COLLISION_GROUP_ALL; + m_CollisionGroup = 0; switch (shapeType) { case dpShapeType::Sphere: @@ -29,7 +29,7 @@ dpEntity::dpEntity(const LWOOBJID& objectID, NiPoint3 boxDimensions, bool isStat m_IsStatic = isStatic; m_CollisionShape = nullptr; m_Scale = 1.0f; - m_CollisionGroup = COLLISION_GROUP_ALL; + m_CollisionGroup = 0; m_CollisionShape = new dpShapeBox(this, boxDimensions.x, boxDimensions.y, boxDimensions.z); } @@ -39,7 +39,7 @@ dpEntity::dpEntity(const LWOOBJID& objectID, float width, float height, float de m_IsStatic = isStatic; m_CollisionShape = nullptr; m_Scale = 1.0f; - m_CollisionGroup = COLLISION_GROUP_ALL; + m_CollisionGroup = 0; m_CollisionShape = new dpShapeBox(this, width, height, depth); } @@ -49,7 +49,7 @@ dpEntity::dpEntity(const LWOOBJID& objectID, float radius, bool isStatic) { m_IsStatic = isStatic; m_CollisionShape = nullptr; m_Scale = 1.0f; - m_CollisionGroup = COLLISION_GROUP_ALL; + m_CollisionGroup = 0; m_CollisionShape = new dpShapeSphere(this, radius); } @@ -70,9 +70,7 @@ void dpEntity::Update(float deltaTime) { void dpEntity::CheckCollision(dpEntity* other) { if (!m_CollisionShape) return; - if ((m_CollisionGroup & other->m_CollisionGroup) & (~COLLISION_GROUP_DYNAMIC)) { - return; - } + if (!dpCollisionFilter::ShouldCollide(m_CollisionGroup, other->m_CollisionGroup)) return; const auto objId = other->GetObjectID(); const auto objItr = m_CurrentlyCollidingObjects.find(objId); diff --git a/dPhysics/dpEntity.h b/dPhysics/dpEntity.h index a43da4c39..1c302bf11 100644 --- a/dPhysics/dpEntity.h +++ b/dPhysics/dpEntity.h @@ -8,7 +8,7 @@ #include "dCommonVars.h" #include "dpCommon.h" #include "dpShapeBase.h" -#include "dpCollisionGroups.h" +#include "dpCollisionFilter.h" #include "dpGrid.h" class dpEntity { @@ -44,8 +44,9 @@ public: bool GetIsStatic() const { return m_IsStatic; } - uint8_t GetCollisionGroup() const { return m_CollisionGroup; } - void SetCollisionGroup(uint8_t value) { m_CollisionGroup = value; } + // The collision filter value (see dpCollisionFilter): the client's collision group, 0 touches everything + uint32_t GetCollisionGroup() const { return m_CollisionGroup; } + void SetCollisionGroup(uint32_t value) { m_CollisionGroup = value; } bool GetSleeping() const { return m_Sleeping; } void SetSleeping(bool value) { m_Sleeping = value; } @@ -76,7 +77,7 @@ private: dpGrid* m_Grid = nullptr; - uint8_t m_CollisionGroup; + uint32_t m_CollisionGroup; bool m_Sleeping = false; bool m_IsGargantuan = false; diff --git a/dPhysics/dpShapeBox.cpp b/dPhysics/dpShapeBox.cpp index bfd72deac..8c1fcdcea 100644 --- a/dPhysics/dpShapeBox.cpp +++ b/dPhysics/dpShapeBox.cpp @@ -7,6 +7,7 @@ #include "NiPoint3.h" #include "NiQuaternion.h" +#include #include dpShapeBox::dpShapeBox(dpEntity* parentEntity, float width, float height, float depth) : @@ -78,6 +79,7 @@ void dpShapeBox::SetScale(float scale) { void dpShapeBox::SetRotation(const NiQuaternion& rotation) { if (m_HasBeenRotated) return; //Boxes cannot be rotated more than once. m_HasBeenRotated = true; + m_Orientation = rotation; m_TopMinLeft = m_TopMinLeft.RotateByQuaternion(rotation); m_TopMaxLeft = m_TopMaxLeft.RotateByQuaternion(rotation); @@ -93,19 +95,17 @@ void dpShapeBox::SetRotation(const NiQuaternion& rotation) { } bool dpShapeBox::IsVertInBox(const NiPoint3& vert) { - //if we are in the correct height - if (vert.y >= m_MinY && vert.y <= m_MaxY) { + return SquaredDistanceTo(vert) <= 0.0f; +} - //if we're inside the x bounds - if (vert.x >= m_MinX && vert.x <= m_MaxX) { +float dpShapeBox::SquaredDistanceTo(const NiPoint3& point) const { + // Into the box's frame: its origin is the middle of its bottom face + const auto local = (point - m_Origin).RotateByQuaternion(glm::conjugate(m_Orientation)); - //if we're inside the z bounds - if (vert.z >= m_MinZ && vert.z <= m_MaxZ) - return true; - } - } - - return false; + const float dX = local.x - std::clamp(local.x, -m_Width, m_Width); + const float dY = local.y - std::clamp(local.y, 0.0f, m_Height * 2.0f); + const float dZ = local.z - std::clamp(local.z, -m_Depth, m_Depth); + return dX * dX + dY * dY + dZ * dZ; } void dpShapeBox::InitVertices() { @@ -129,6 +129,7 @@ void dpShapeBox::InitVertices() { void dpShapeBox::SetPosition(const NiPoint3& position) { if (isTransformed) return; isTransformed = true; + m_Origin = position; for (auto& vert : m_Vertices) { vert.x += position.x; diff --git a/dPhysics/dpShapeBox.h b/dPhysics/dpShapeBox.h index 4af0396e1..3619e75eb 100644 --- a/dPhysics/dpShapeBox.h +++ b/dPhysics/dpShapeBox.h @@ -33,6 +33,12 @@ public: bool IsVertInBox(const NiPoint3& vert); + /** + * The squared distance from a point to the box, measured along the box's own (rotated) axes. The m_Min/m_Max + * bounds are the axis aligned box around the rotated one, which for a rotated wall is far bigger than the wall. + */ + float SquaredDistanceTo(const NiPoint3& point) const; + void InitVertices(); void SetPosition(const NiPoint3& position); @@ -64,6 +70,10 @@ private: float m_Scale; + // Where the box was placed and how it was turned, to test against its real shape + NiPoint3 m_Origin{}; + NiQuaternion m_Orientation = QuatUtils::IDENTITY; + bool m_HasBeenRotated = false; bool isScaled = false; bool isTransformed = false; diff --git a/dScripts/02_server/Map/AM/AmShieldGenerator.cpp b/dScripts/02_server/Map/AM/AmShieldGenerator.cpp index 29b09e12e..22de7faa2 100644 --- a/dScripts/02_server/Map/AM/AmShieldGenerator.cpp +++ b/dScripts/02_server/Map/AM/AmShieldGenerator.cpp @@ -9,8 +9,9 @@ #include "SkillComponent.h" void AmShieldGenerator::OnStartup(Entity* self) { - self->SetProximityRadius(20, "shield"); - self->SetProximityRadius(21, "buffer"); + // Enemies (the client group table: 10 sees group 12) and players (1 sees group 10), like live + self->SetProximityRadius(20, "shield", 10); + self->SetProximityRadius(21, "buffer", 1); StartShield(self); } diff --git a/dScripts/02_server/Map/AM/AmShieldGeneratorQuickbuild.cpp b/dScripts/02_server/Map/AM/AmShieldGeneratorQuickbuild.cpp index 68ed26ff0..ebbf2f851 100644 --- a/dScripts/02_server/Map/AM/AmShieldGeneratorQuickbuild.cpp +++ b/dScripts/02_server/Map/AM/AmShieldGeneratorQuickbuild.cpp @@ -11,8 +11,9 @@ #include "MissionComponent.h" void AmShieldGeneratorQuickbuild::OnStartup(Entity* self) { - self->SetProximityRadius(20, "shield"); - self->SetProximityRadius(21, "buffer"); + // Enemies (the client group table: 10 sees group 12) and players (1 sees group 10), like live + self->SetProximityRadius(20, "shield", 10); + self->SetProximityRadius(21, "buffer", 1); } void AmShieldGeneratorQuickbuild::OnProximityUpdate(Entity* self, Entity* entering, std::string name, std::string status) { diff --git a/tests/dGameTests/CMakeLists.txt b/tests/dGameTests/CMakeLists.txt index a48269c3a..5f87aaa71 100644 --- a/tests/dGameTests/CMakeLists.txt +++ b/tests/dGameTests/CMakeLists.txt @@ -8,6 +8,7 @@ set(DGAMETEST_SOURCES "StaleSaveGuardTests.cpp" "ContrabandTests.cpp" "KnockbackTests.cpp" + "CollisionFilterTests.cpp" ) add_subdirectory(dComponentsTests) diff --git a/tests/dGameTests/CollisionFilterTests.cpp b/tests/dGameTests/CollisionFilterTests.cpp new file mode 100644 index 000000000..b53bb288b --- /dev/null +++ b/tests/dGameTests/CollisionFilterTests.cpp @@ -0,0 +1,79 @@ +#include + +#include "dpCollisionFilter.h" +#include "dpEntity.h" +#include "dpShapeBox.h" + +using namespace dpCollisionFilter; + +namespace { + constexpr uint32_t PLAYER = 10; + constexpr uint32_t ENEMY = 12; +} + +TEST(CollisionFilterTests, GroupZeroTouchesEverything) { + EXPECT_TRUE(ShouldCollide(0, PLAYER)); + EXPECT_TRUE(ShouldCollide(ENEMY, 0)); + EXPECT_TRUE(ShouldCollide(0, 0)); +} + +TEST(CollisionFilterTests, TableIsSymmetric) { + for (uint32_t a = 1; a <= GROUP_COUNT; a++) { + for (uint32_t b = 1; b <= GROUP_COUNT; b++) EXPECT_EQ(GroupsCollide(a, b), GroupsCollide(b, a)) << a << " " << b; + } +} + +TEST(CollisionFilterTests, LiveProximityGroups) { + // The AM shield generator: group 10 to find enemies, group 1 to find players + EXPECT_TRUE(ShouldCollide(10, ENEMY)); + EXPECT_FALSE(ShouldCollide(10, PLAYER)); + EXPECT_TRUE(ShouldCollide(1, PLAYER)); + EXPECT_FALSE(ShouldCollide(1, ENEMY)); +} + +TEST(CollisionFilterTests, TriggerWalls) { + // POI trigger walls (group 1) ignore enemies; "Clear threat list" walls (18) only catch enemies + EXPECT_FALSE(ShouldCollide(1, ENEMY)); + EXPECT_TRUE(ShouldCollide(18, ENEMY)); + EXPECT_FALSE(ShouldCollide(18, PLAYER)); + // Death volumes (4) catch both + EXPECT_TRUE(ShouldCollide(4, PLAYER)); + EXPECT_TRUE(ShouldCollide(4, ENEMY)); + // Groups 21 and 25 touch nothing + EXPECT_FALSE(ShouldCollide(21, PLAYER)); + EXPECT_FALSE(ShouldCollide(25, ENEMY)); +} + +TEST(CollisionFilterTests, Flags) { + // Two phantom-only objects never touch, one is ignored + EXPECT_FALSE(ShouldCollide(PHANTOM_ONLY | 4, PHANTOM_ONLY | PLAYER)); + EXPECT_TRUE(ShouldCollide(PHANTOM_ONLY | 4, PLAYER)); + // A group mask touches exactly the groups in it + const auto playersOnly = GROUP_MASK | (1u << (PLAYER - 1)); + EXPECT_TRUE(ShouldCollide(playersOnly, PLAYER)); + EXPECT_FALSE(ShouldCollide(ENEMY, playersOnly)); + EXPECT_FALSE(ShouldCollide(playersOnly, playersOnly)); + // Parts of the same system don't touch each other + EXPECT_FALSE(ShouldCollide(0x08000000 | ENEMY, 0x08000000 | ENEMY)); + EXPECT_TRUE(ShouldCollide(0x08000000 | ENEMY, 0x10000000 | ENEMY)); +} + +TEST(CollisionFilterTests, RotatedBoxesKeepTheirShape) { + // A 40 long, 1 thick wall turned 45 degrees: its axis aligned bounds are a ~29x29 square, but only the wall counts + dpEntity wall(1, 40.0f, 10.0f, 1.0f); + wall.SetRotation(QuatUtils::AxisAngle(NiPoint3(0.0f, 1.0f, 0.0f), glm::radians(45.0f))); + wall.SetPosition(NiPoint3(100.0f, 0.0f, 100.0f)); + auto* box = static_cast(wall.GetShape()); + + // Along the wall + const auto along = QuatUtils::AxisAngle(NiPoint3(0.0f, 1.0f, 0.0f), glm::radians(45.0f)); + const auto onWall = NiPoint3(100.0f, 5.0f, 100.0f) + NiPoint3(15.0f, 0.0f, 0.0f).RotateByQuaternion(along); + EXPECT_FLOAT_EQ(box->SquaredDistanceTo(onWall), 0.0f); + + // A corner of the old axis aligned box, far from the wall + EXPECT_GT(box->SquaredDistanceTo(NiPoint3(112.0f, 5.0f, 112.0f)), 100.0f); + + // Above the top and below the bottom + EXPECT_NEAR(box->SquaredDistanceTo(NiPoint3(100.0f, 12.0f, 100.0f)), 4.0f, 1e-3f); + EXPECT_NEAR(box->SquaredDistanceTo(NiPoint3(100.0f, -3.0f, 100.0f)), 9.0f, 1e-3f); +}