diff --git a/dGame/dComponents/MovementAIComponent.cpp b/dGame/dComponents/MovementAIComponent.cpp index 73987970b..e56d42757 100644 --- a/dGame/dComponents/MovementAIComponent.cpp +++ b/dGame/dComponents/MovementAIComponent.cpp @@ -462,6 +462,14 @@ void MovementAIComponent::SetDestination(const NiPoint3 destination) { } } + // The path is walked without collision, so an enemy's stops at walls it can't cross (navmesh carvers, enemy only + // blockers); with nothing left to walk it stays where it is. A patrol along its level path is left as designed. + const bool patrolling = m_Path && m_BaseCombatAI && m_BaseCombatAI->GetState() == AiState::idle; + if (m_BaseCombatAI && !patrolling) { + computedPath = dpWorld::ClampPath(m_Parent->GetPosition(), std::move(computedPath), static_cast(m_Parent->GetCollisionGroup())); + if (computedPath.empty()) computedPath.push_back(m_Parent->GetPosition()); + } + m_InterpolatedWaypoints.clear(); // Simply path diff --git a/tests/dGameTests/dComponentsTests/CMakeLists.txt b/tests/dGameTests/dComponentsTests/CMakeLists.txt index 6950ce482..6efad2888 100644 --- a/tests/dGameTests/dComponentsTests/CMakeLists.txt +++ b/tests/dGameTests/dComponentsTests/CMakeLists.txt @@ -9,6 +9,7 @@ set(DCOMPONENTS_TESTS "DestroyableComponentTests.cpp" "PetComponentTests.cpp" "SimplePhysicsComponentTests.cpp" + "MovementBlockerScenarioTests.cpp" "SavingTests.cpp" "CharacterSaveFieldsTests.cpp" "CollectibleComponentTests.cpp" diff --git a/tests/dGameTests/dComponentsTests/MovementBlockerScenarioTests.cpp b/tests/dGameTests/dComponentsTests/MovementBlockerScenarioTests.cpp new file mode 100644 index 000000000..d2a83af47 --- /dev/null +++ b/tests/dGameTests/dComponentsTests/MovementBlockerScenarioTests.cpp @@ -0,0 +1,138 @@ +#include "GameDependencies.h" +#include + +#include "GameMessages.h" +#include "BaseCombatAIComponent.h" +#include "CDClientDatabase.h" +#include "CDComponentsRegistryTable.h" +#include "CDPhysicsComponentTable.h" +#include "dpEntity.h" +#include "dpShapeBox.h" +#include "dpWorld.h" +#include "Entity.h" +#include "eReplicaComponentType.h" +#include "MovementAIComponent.h" +#include "PhantomPhysicsComponent.h" +#include "SimplePhysicsComponent.h" + +// An enemy chasing across a wall it can't cross stops in front of it, the way the walls are set up in the level files +class MovementBlockerScenarioTest : public GameDependenciesTest { +protected: + static constexpr LOT ENEMY_BLOCKER_LOT = 9709; // FV - Enemy Blocking Volume: solid, group 18 + static constexpr LOT CARVER_LOT = 8419; // Trigger Wall: phantom, group 7, navmesh_carver on the Sentinel camp's + static constexpr LOT CLEAR_THREAT_LOT = 13632; // Clear threat list Trigger Wall: phantom, group 18 + static constexpr int32_t ENEMY_BLOCKER_PHYSICS = 4237; + static constexpr int32_t CARVER_PHYSICS = 3901; + static constexpr int32_t CLEAR_THREAT_PHYSICS = 6691; + + void SetUp() override { + SetUpDependencies(); + if (!CDClientDatabase::isConnected) { + CDClientDatabase::Connect(":memory:"); + for (const auto* table : { + "ComponentsRegistry (id INTEGER, component_type INTEGER, component_id INTEGER)", + "BaseCombatAIComponent (id INTEGER, aggroRadius REAL, tetherSpeed REAL, pursuitSpeed REAL, softTetherRadius REAL, hardTetherRadius REAL, minRoundLength REAL, maxRoundLength REAL, combatRoundLength REAL)", + "ObjectSkills (objectTemplate INTEGER, skillID INTEGER, castOnType INTEGER, AICombatWeight INTEGER)", + "SkillBehavior (skillID INTEGER, behaviorID INTEGER)", + }) { + CDClientDatabase::ExecuteDML(std::string("CREATE TABLE ") + table + ";"); + } + } + + AddPhysics(ENEMY_BLOCKER_LOT, eReplicaComponentType::SIMPLE_PHYSICS, ENEMY_BLOCKER_PHYSICS, "miscellaneous\\misc_phys_10x1x5.hkx", 18); + AddPhysics(CARVER_LOT, eReplicaComponentType::PHANTOM_PHYSICS, CARVER_PHYSICS, "miscellaneous\\misc_phys_10x1x5.hkx", 7); + AddPhysics(CLEAR_THREAT_LOT, eReplicaComponentType::PHANTOM_PHYSICS, CLEAR_THREAT_PHYSICS, "test\\POI_trigger_wall.hkx", 18); + CDClientManager::GetEntriesMutable().insert_or_assign(static_cast(info.lot), 0); + } + + void TearDown() override { + TearDownDependencies(); + } + + void AddPhysics(const LOT lot, const eReplicaComponentType type, const int32_t id, const std::string& asset, const int32_t group) { + auto& registry = CDClientManager::GetEntriesMutable(); + registry.insert_or_assign(static_cast(type) << 32 | static_cast(lot), id); + registry.insert_or_assign(static_cast(lot), 0); + CDPhysicsComponent row{}; + row.id = id; + row.physicsAsset = asset; + row.collisionGroup = group; + CDClientManager::GetEntriesMutable().insert_or_assign(static_cast(id), row); + } + + // A wall across z = 0 at the origin, 10 wide along x + std::unique_ptr Wall(const LOT lot, const LWOOBJID id) { + EntityInfo wallInfo = info; + wallInfo.lot = lot; + wallInfo.pos = NiPoint3Constant::ZERO; + return std::make_unique(id, wallInfo); + } + + // An enemy 10 in front of the wall, chasing something 10 behind it + NiPoint3 ChaseAcross(Entity& enemy) { + // Something to hold its position, as its controllable physics would + enemy.AddComponent(-1); + enemy.AddComponent(-1); + auto* const movement = enemy.AddComponent(-1, MovementAIInfo{}); + movement->SetDestination({ 0.0f, 0.0f, 10.0f }); + return movement->GetDestination(); + } +}; + +TEST_F(MovementBlockerScenarioTest, EnemyOnlyBlockerStopsTheChase) { + auto wall = Wall(ENEMY_BLOCKER_LOT, 100); + wall->AddComponent(ENEMY_BLOCKER_PHYSICS); + ASSERT_EQ(dpWorld::GetMovementBlockers().size(), 1u); + + EntityInfo enemyInfo = info; + enemyInfo.pos = { 0.0f, 0.0f, -10.0f }; + Entity enemy(200, enemyInfo); + const auto destination = ChaseAcross(enemy); + // The wall's near face is z = -0.5 + EXPECT_LT(destination.z, -0.5f); + EXPECT_GT(destination.z, -3.0f); + + wall.reset(); + EXPECT_TRUE(dpWorld::GetMovementBlockers().empty()); +} + +TEST_F(MovementBlockerScenarioTest, NavmeshCarverStopsTheChase) { + auto wall = Wall(CARVER_LOT, 101); + wall->SetVar(u"navmesh_carver", true); + wall->AddComponent(CARVER_PHYSICS); + ASSERT_EQ(dpWorld::GetMovementBlockers().size(), 1u); + + EntityInfo enemyInfo = info; + enemyInfo.pos = { 0.0f, 0.0f, -10.0f }; + Entity enemy(201, enemyInfo); + EXPECT_LT(ChaseAcross(enemy).z, -0.5f); +} + +TEST_F(MovementBlockerScenarioTest, ClearThreatWallIsATriggerNotAWall) { + auto wall = Wall(CLEAR_THREAT_LOT, 102); + auto* const phantom = wall->AddComponent(CLEAR_THREAT_PHYSICS); + EXPECT_TRUE(dpWorld::GetMovementBlockers().empty()); + // Its trigger is the wall's real shape now, not a stand in cube + ASSERT_NE(phantom->GetdpEntity(), nullptr); + const auto* const box = dynamic_cast(phantom->GetdpEntity()->GetShape()); + ASSERT_NE(box, nullptr); + EXPECT_NEAR(box->m_MaxY - box->m_MinY, 12.9755f, 1e-3f); + + EntityInfo enemyInfo = info; + enemyInfo.pos = { 0.0f, 0.0f, -10.0f }; + Entity enemy(202, enemyInfo); + EXPECT_NEAR(ChaseAcross(enemy).z, 10.0f, 1e-3f); +} + +TEST_F(MovementBlockerScenarioTest, NonEnemyMoverIsNotClamped) { + auto wall = Wall(ENEMY_BLOCKER_LOT, 103); + wall->AddComponent(ENEMY_BLOCKER_PHYSICS); + + EntityInfo npcInfo = info; + npcInfo.pos = { 0.0f, 0.0f, -10.0f }; + Entity npc(203, npcInfo); + npc.AddComponent(-1); + auto* const movement = npc.AddComponent(-1, MovementAIInfo{}); + movement->SetDestination({ 0.0f, 0.0f, 10.0f }); + EXPECT_NEAR(movement->GetDestination().z, 10.0f, 1e-3f); +}