mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 02:43:44 +00:00
The client only simulates knockbacks on the character it controls (LWOControllablePhysComponent::msgKnockback); enemies and NPCs are drawn where the server puts them, so a knockback on them did nothing and scripts stunned them instead. MovementAIComponent::Knockback now flies the object the way the client flies its character: a vector longer than 5 lifts it 0.5, throws it with that velocity under WorldConfig gravity (times its gravity scale) until it lands on the navmesh, no sooner than 250ms later; a shorter one moves it by the vector. Walls taller than a step stop sideways motion, the landing is put back onto the navmesh, and pathing and the combat AI wait until it lands (destinations set mid air are walked to afterwards). Position and velocity go out in the normal serialization every tick. KnockbackBehavior builds the vector like the client's Cast (strength capped at 300, angle as elevation, relative, caster and ignore_self) and knocks back server moved targets that aren't immune, both when the server casts and when a client's skill hits. The blocked bit it writes now also answers for the target's knockback immunity, so a player whose client hasn't caught up with a Personal Fortress isn't knocked out of it. The AM shield generators knock enemies back like live instead of stunning them. Fixes #257 Refs #185 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
957 lines
26 KiB
C++
957 lines
26 KiB
C++
#include "BaseCombatAIComponent.h"
|
|
#include "BitStream.h"
|
|
|
|
#include "Entity.h"
|
|
#include "EntityManager.h"
|
|
#include "ControllablePhysicsComponent.h"
|
|
#include "MovementAIComponent.h"
|
|
#include "dpWorld.h"
|
|
|
|
#include "GameMessages.h"
|
|
#include "EffectsMessages.h"
|
|
#include "dServer.h"
|
|
#include "Game.h"
|
|
|
|
#include "CDClientDatabase.h"
|
|
#include "CDClientManager.h"
|
|
#include "CDObjectSkillsTable.h"
|
|
#include "CDSkillBehaviorTable.h"
|
|
#include "DestroyableComponent.h"
|
|
|
|
#include <algorithm>
|
|
#include <ranges>
|
|
#include <sstream>
|
|
#include <vector>
|
|
|
|
#include "SkillComponent.h"
|
|
#include "QuickBuildComponent.h"
|
|
#include "DestroyableComponent.h"
|
|
#include "CDComponentsRegistryTable.h"
|
|
#include "CDPhysicsComponentTable.h"
|
|
#include "dNavMesh.h"
|
|
#include "Amf3.h"
|
|
|
|
BaseCombatAIComponent::BaseCombatAIComponent(Entity* parent, const int32_t componentID) : Component(parent, componentID) {
|
|
RegisterMsg(&BaseCombatAIComponent::MsgGetObjectReportInfo);
|
|
m_Target = LWOOBJID_EMPTY;
|
|
m_DirtyStateOrTarget = true;
|
|
m_State = AiState::spawn;
|
|
m_Timer = 1.0f;
|
|
m_StartPosition = parent->GetPosition();
|
|
m_MovementAI = nullptr;
|
|
m_Disabled = false;
|
|
m_SkillEntries = {};
|
|
m_SoftTimer = 5.0f;
|
|
m_ForcedTetherTime = 0.0f;
|
|
|
|
//Grab the aggro information from BaseCombatAI:
|
|
auto componentQuery = CDClientDatabase::CreatePreppedStmt(
|
|
"SELECT aggroRadius, tetherSpeed, pursuitSpeed, softTetherRadius, hardTetherRadius, minRoundLength, maxRoundLength, combatRoundLength FROM BaseCombatAIComponent WHERE id = ?;");
|
|
componentQuery.bind(1, static_cast<int>(componentID));
|
|
|
|
auto componentResult = componentQuery.execQuery();
|
|
|
|
if (!componentResult.eof()) {
|
|
if (!componentResult.fieldIsNull("aggroRadius"))
|
|
m_AggroRadius = componentResult.getFloatField("aggroRadius");
|
|
|
|
if (!componentResult.fieldIsNull("tetherSpeed"))
|
|
m_TetherSpeed = componentResult.getFloatField("tetherSpeed");
|
|
|
|
if (!componentResult.fieldIsNull("pursuitSpeed"))
|
|
m_PursuitSpeed = componentResult.getFloatField("pursuitSpeed");
|
|
|
|
if (!componentResult.fieldIsNull("softTetherRadius"))
|
|
m_SoftTetherRadius = componentResult.getFloatField("softTetherRadius");
|
|
|
|
if (!componentResult.fieldIsNull("hardTetherRadius"))
|
|
m_HardTetherRadius = componentResult.getFloatField("hardTetherRadius");
|
|
|
|
m_MinRoundLength = componentResult.getFloatField("minRoundLength");
|
|
m_MaxRoundLength = componentResult.getFloatField("maxRoundLength");
|
|
m_CombatRoundLength = componentResult.getFloatField("combatRoundLength");
|
|
}
|
|
|
|
// Get aggro and tether radius from settings and use this if it is present. Only overwrite the
|
|
// radii if it is greater than the one in the database.
|
|
m_AggroRadius = m_Parent->HasVar(u"aggroRadius") ? m_Parent->GetVar<float>(u"aggroRadius") : m_AggroRadius;
|
|
m_HardTetherRadius = m_Parent->HasVar(u"tetherRadius") ? m_Parent->GetVar<float>(u"tetherRadius") : m_HardTetherRadius;
|
|
|
|
/*
|
|
* Find skills
|
|
*/
|
|
for (const auto objectSkill : CDClientManager::GetTable<CDObjectSkillsTable>()->Get(parent->GetLOT())) {
|
|
const auto skillBehavior = CDClientManager::GetTable<CDSkillBehaviorTable>()->GetSkillByID(objectSkill.skillID);
|
|
if (skillBehavior.skillID == objectSkill.skillID) {
|
|
const auto skillId = skillBehavior.skillID;
|
|
|
|
const auto abilityCooldown = skillBehavior.cooldown;
|
|
|
|
const auto behaviorId = skillBehavior.behaviorID;
|
|
|
|
const auto combatWeight = objectSkill.AICombatWeight;
|
|
|
|
auto* behavior = Behavior::CreateBehavior(behaviorId);
|
|
|
|
AiSkillEntry entry = { .skillId = skillId, .cooldown = 0.0f, .abilityCooldown = abilityCooldown, .behavior = behavior, .combatWeight = combatWeight };
|
|
|
|
m_SkillEntries.push_back(entry);
|
|
}
|
|
}
|
|
|
|
Stun(1.0f);
|
|
|
|
/*
|
|
* Add physics
|
|
*/
|
|
|
|
int32_t collisionGroup = (COLLISION_GROUP_DYNAMIC | COLLISION_GROUP_ENEMY);
|
|
|
|
CDComponentsRegistryTable* componentRegistryTable = CDClientManager::GetTable<CDComponentsRegistryTable>();
|
|
const auto controllablePhysicsID = componentRegistryTable->GetByIDAndType(parent->GetLOT(), eReplicaComponentType::CONTROLLABLE_PHYSICS);
|
|
|
|
CDPhysicsComponentTable* physicsComponentTable = CDClientManager::GetTable<CDPhysicsComponentTable>();
|
|
|
|
if (physicsComponentTable != nullptr) {
|
|
auto* info = physicsComponentTable->GetByID(controllablePhysicsID);
|
|
if (info != nullptr) {
|
|
collisionGroup = info->bStatic ? COLLISION_GROUP_NEUTRAL : info->collisionGroup;
|
|
}
|
|
}
|
|
|
|
//Create a phantom physics volume so we can detect when we're aggro'd.
|
|
m_dpEntity = new dpEntity(m_Parent->GetObjectID(), m_AggroRadius);
|
|
m_dpEntityEnemy = new dpEntity(m_Parent->GetObjectID(), m_AggroRadius, false);
|
|
|
|
m_dpEntity->SetCollisionGroup(collisionGroup);
|
|
m_dpEntityEnemy->SetCollisionGroup(collisionGroup);
|
|
|
|
m_dpEntity->SetPosition(m_Parent->GetPosition());
|
|
m_dpEntityEnemy->SetPosition(m_Parent->GetPosition());
|
|
|
|
dpWorld::AddEntity(m_dpEntity);
|
|
dpWorld::AddEntity(m_dpEntityEnemy);
|
|
|
|
}
|
|
|
|
BaseCombatAIComponent::~BaseCombatAIComponent() {
|
|
if (m_dpEntity)
|
|
dpWorld::RemoveEntity(m_dpEntity);
|
|
|
|
if (m_dpEntityEnemy)
|
|
dpWorld::RemoveEntity(m_dpEntityEnemy);
|
|
}
|
|
|
|
void BaseCombatAIComponent::Update(const float deltaTime) {
|
|
//First, we need to process physics:
|
|
if (!m_dpEntity) return;
|
|
|
|
m_dpEntity->SetPosition(m_Parent->GetPosition()); //make sure our position is synced with our dpEntity
|
|
m_dpEntityEnemy->SetPosition(m_Parent->GetPosition());
|
|
|
|
//Process enter events
|
|
for (const auto id : m_dpEntity->GetNewObjects()) {
|
|
m_Parent->OnCollisionPhantom(id);
|
|
}
|
|
|
|
//Process exit events
|
|
for (const auto id : m_dpEntity->GetRemovedObjects()) {
|
|
m_Parent->OnCollisionLeavePhantom(id);
|
|
}
|
|
|
|
// Check if we should stop the tether effect
|
|
if (m_TetherEffectActive) {
|
|
m_TetherTime -= deltaTime;
|
|
m_ForcedTetherTime -= deltaTime;
|
|
if (m_ForcedTetherTime >= 0) return;
|
|
GameMessages::StopFXEffect(m_Parent->GetObjectID(), true, "tether").Send(UNASSIGNED_SYSTEM_ADDRESS);
|
|
m_TetherEffectActive = false;
|
|
}
|
|
|
|
for (auto entry = m_RemovedThreatList.begin(); entry != m_RemovedThreatList.end();) {
|
|
entry->second -= deltaTime;
|
|
if (entry->second <= 0.0f) {
|
|
entry = m_RemovedThreatList.erase(entry);
|
|
} else {
|
|
++entry;
|
|
}
|
|
}
|
|
|
|
if (m_SoftTimer <= 0.0f) {
|
|
Game::entityManager->SerializeEntity(m_Parent);
|
|
|
|
m_SoftTimer = 5.0f;
|
|
} else {
|
|
m_SoftTimer -= deltaTime;
|
|
}
|
|
|
|
if (m_Disabled || m_Parent->GetIsDead())
|
|
return;
|
|
bool stunnedThisFrame = m_Stunned;
|
|
CalculateCombat(deltaTime); // Putting this here for now
|
|
|
|
if (m_StartPosition == NiPoint3Constant::ZERO) {
|
|
m_StartPosition = m_Parent->GetPosition();
|
|
}
|
|
|
|
m_MovementAI = m_Parent->GetComponent<MovementAIComponent>();
|
|
|
|
if (m_MovementAI == nullptr) {
|
|
return;
|
|
}
|
|
|
|
if (stunnedThisFrame) {
|
|
if (!m_MovementAI->IsPaused()) m_MovementAI->Pause();
|
|
|
|
// in this case we just become unstunned so check if we paused and resume if we did
|
|
if (!m_Stunned && m_MovementAI->IsPaused()) m_MovementAI->Resume();
|
|
return;
|
|
}
|
|
|
|
// Flying from a knockback: pick a destination again once we land
|
|
if (m_MovementAI->IsKnockedBack()) return;
|
|
|
|
if (m_Timer > 0.0f) {
|
|
m_Timer -= deltaTime;
|
|
return;
|
|
}
|
|
|
|
switch (m_State) {
|
|
case AiState::spawn:
|
|
Stun(2.0f);
|
|
SetAiState(AiState::idle);
|
|
break;
|
|
|
|
case AiState::idle:
|
|
Wander();
|
|
break;
|
|
|
|
case AiState::aggro:
|
|
OnAggro();
|
|
break;
|
|
|
|
case AiState::tether:
|
|
OnTether();
|
|
break;
|
|
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
|
|
void BaseCombatAIComponent::CalculateCombat(const float deltaTime) {
|
|
bool hasSkillToCast = false;
|
|
int32_t maxSkillWeights = 0;
|
|
for (auto& entry : m_SkillEntries) {
|
|
if (entry.cooldown > 0.0f) {
|
|
entry.cooldown -= deltaTime;
|
|
} else {
|
|
maxSkillWeights += entry.combatWeight;
|
|
hasSkillToCast = true;
|
|
}
|
|
}
|
|
|
|
bool hadRemainingDowntime = m_SkillTime > 0.0f;
|
|
if (m_SkillTime > 0.0f) m_SkillTime -= deltaTime;
|
|
|
|
auto* rebuild = m_Parent->GetComponent<QuickBuildComponent>();
|
|
|
|
if (rebuild != nullptr) {
|
|
const auto state = rebuild->GetState();
|
|
|
|
if (state != eQuickBuildState::COMPLETED) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
auto* skillComponent = m_Parent->GetComponent<SkillComponent>();
|
|
|
|
if (skillComponent == nullptr) {
|
|
return;
|
|
}
|
|
|
|
skillComponent->CalculateUpdate(deltaTime);
|
|
|
|
if (m_Disabled) return;
|
|
|
|
if (m_Stunned) {
|
|
m_StunTime -= deltaTime;
|
|
|
|
if (m_StunTime > 0.0f) {
|
|
return;
|
|
}
|
|
m_StunTime = 0.0f;
|
|
m_Stunned = false;
|
|
}
|
|
|
|
if (m_Stunned || hadRemainingDowntime) return;
|
|
|
|
auto newTarget = FindTarget();
|
|
|
|
// Tether - reset enemy
|
|
if (m_Target != LWOOBJID_EMPTY && newTarget == LWOOBJID_EMPTY) {
|
|
m_OutOfCombat = true;
|
|
m_OutOfCombatTime = 1.0f;
|
|
} else if (newTarget != LWOOBJID_EMPTY) {
|
|
m_OutOfCombat = false;
|
|
m_OutOfCombatTime = 0.0f;
|
|
}
|
|
|
|
if (!m_TetherEffectActive && m_OutOfCombat && (m_OutOfCombatTime -= deltaTime) <= 0) {
|
|
TetherLogic();
|
|
|
|
m_OutOfCombat = false;
|
|
m_OutOfCombatTime = 0.0f;
|
|
}
|
|
|
|
SetTarget(newTarget);
|
|
|
|
if (m_Target != LWOOBJID_EMPTY) {
|
|
if (m_State == AiState::idle) {
|
|
m_Timer = 0;
|
|
}
|
|
|
|
SetAiState(AiState::aggro);
|
|
} else {
|
|
SetAiState(AiState::idle);
|
|
// Don't clobber the pursuit speed set by TetherLogic while the tether is still active,
|
|
// otherwise the entity slows to wander speed and takes far longer than m_TetherTime to
|
|
// return to its spawn point.
|
|
if (m_MovementAI && !m_TetherEffectActive) m_MovementAI->SetMaxSpeed(1.0f);
|
|
}
|
|
|
|
if (!hasSkillToCast) return;
|
|
|
|
if (m_Target == LWOOBJID_EMPTY) {
|
|
SetAiState(AiState::idle);
|
|
if (m_MovementAI && !m_TetherEffectActive) m_MovementAI->SetMaxSpeed(1.0f);
|
|
|
|
return;
|
|
}
|
|
|
|
auto* target = GetTargetEntity();
|
|
|
|
if (target != nullptr) {
|
|
LookAt(target->GetPosition());
|
|
}
|
|
|
|
// Roll to find which skill we'll try to cast
|
|
auto randomizedWeight = GeneralUtils::GenerateRandomNumber<int32_t>(0, maxSkillWeights);
|
|
|
|
for (auto& entry : m_SkillEntries) {
|
|
// Skill isn't cooled off yet
|
|
if (entry.cooldown > 0.0f) {
|
|
continue;
|
|
}
|
|
|
|
randomizedWeight -= entry.combatWeight;
|
|
|
|
// if the weight is still greater than 0 continue to the next rolled skill
|
|
if (randomizedWeight > 0) {
|
|
continue;
|
|
}
|
|
|
|
const auto result = skillComponent->CalculateBehavior(entry.skillId, entry.behavior->m_behaviorId, GetTarget());
|
|
|
|
if (result.success) {
|
|
if (m_MovementAI != nullptr) {
|
|
m_MovementAI->Stop();
|
|
}
|
|
|
|
SetAiState(AiState::aggro);
|
|
|
|
m_Timer = 0;
|
|
|
|
m_SkillTime = result.skillTime;
|
|
|
|
entry.cooldown = entry.abilityCooldown + m_SkillTime;
|
|
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
LWOOBJID BaseCombatAIComponent::FindTarget() {
|
|
NiPoint3 reference = m_StartPosition;
|
|
|
|
if (m_MovementAI) reference = m_MovementAI->ApproximateLocation();
|
|
|
|
auto* target = GetTargetEntity();
|
|
|
|
// A target that died is no longer worth attacking, drop it and look for someone else.
|
|
if (target != nullptr && target->GetIsDead()) {
|
|
SetThreat(m_Target, 0.0f);
|
|
target = nullptr;
|
|
m_DirtyThreat = true;
|
|
}
|
|
|
|
if (target != nullptr && !m_DirtyThreat) {
|
|
const auto targetPosition = target->GetPosition();
|
|
|
|
if (Vector3::DistanceSquared(targetPosition, m_StartPosition) < m_HardTetherRadius * m_HardTetherRadius) {
|
|
return m_Target;
|
|
}
|
|
|
|
return LWOOBJID_EMPTY;
|
|
}
|
|
|
|
auto possibleTargets = GetTargetWithinAggroRange();
|
|
|
|
if (possibleTargets.empty() && m_ThreatEntries.empty()) {
|
|
m_DirtyThreat = false;
|
|
|
|
return LWOOBJID_EMPTY;
|
|
}
|
|
|
|
Entity* optimalTarget = nullptr;
|
|
float biggestThreat = 0;
|
|
|
|
for (const auto& entry : possibleTargets) {
|
|
auto* entity = Game::entityManager->GetEntity(entry);
|
|
|
|
if (entity == nullptr || entity->GetIsDead()) {
|
|
continue;
|
|
}
|
|
|
|
const auto targetPosition = entity->GetPosition();
|
|
|
|
const auto threat = GetThreat(entry);
|
|
|
|
const auto maxDistanceSquared = m_HardTetherRadius * m_HardTetherRadius;
|
|
|
|
if (Vector3::DistanceSquared(targetPosition, m_StartPosition) > maxDistanceSquared) {
|
|
if (threat > 0) {
|
|
SetThreat(entry, 0);
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
if (threat > biggestThreat) {
|
|
biggestThreat = threat;
|
|
optimalTarget = entity;
|
|
|
|
continue;
|
|
}
|
|
|
|
const auto proximityThreat = -(Vector3::DistanceSquared(targetPosition, reference) - maxDistanceSquared) / 100; // Proximity threat takes last priority
|
|
|
|
if (proximityThreat > biggestThreat) {
|
|
biggestThreat = proximityThreat;
|
|
optimalTarget = entity;
|
|
}
|
|
}
|
|
|
|
if (!m_DirtyThreat) {
|
|
if (optimalTarget == nullptr) {
|
|
return LWOOBJID_EMPTY;
|
|
} else {
|
|
return optimalTarget->GetObjectID();
|
|
}
|
|
}
|
|
|
|
std::vector<LWOOBJID> deadThreats{};
|
|
|
|
for (const auto& threatTarget : m_ThreatEntries) {
|
|
auto* entity = Game::entityManager->GetEntity(threatTarget.first);
|
|
|
|
if (entity == nullptr || entity->GetIsDead()) {
|
|
deadThreats.push_back(threatTarget.first);
|
|
|
|
continue;
|
|
}
|
|
|
|
const auto targetPosition = entity->GetPosition();
|
|
|
|
if (Vector3::DistanceSquared(targetPosition, m_StartPosition) > m_HardTetherRadius * m_HardTetherRadius) {
|
|
deadThreats.push_back(threatTarget.first);
|
|
|
|
continue;
|
|
}
|
|
|
|
if (threatTarget.second > biggestThreat) {
|
|
optimalTarget = entity;
|
|
biggestThreat = threatTarget.second;
|
|
}
|
|
}
|
|
|
|
for (const auto& deadThreat : deadThreats) {
|
|
SetThreat(deadThreat, 0);
|
|
}
|
|
|
|
m_DirtyThreat = false;
|
|
|
|
if (optimalTarget == nullptr) {
|
|
return LWOOBJID_EMPTY;
|
|
} else {
|
|
return optimalTarget->GetObjectID();
|
|
}
|
|
}
|
|
|
|
std::vector<LWOOBJID> BaseCombatAIComponent::GetTargetWithinAggroRange() const {
|
|
std::vector<LWOOBJID> targets;
|
|
|
|
for (auto id : m_Parent->GetTargetsInPhantom()) {
|
|
auto* other = Game::entityManager->GetEntity(id);
|
|
if (!other) continue;
|
|
|
|
const auto distance = Vector3::DistanceSquared(m_Parent->GetPosition(), other->GetPosition());
|
|
|
|
if (distance > m_AggroRadius * m_AggroRadius || m_RemovedThreatList.contains(id)) continue;
|
|
|
|
targets.push_back(id);
|
|
}
|
|
|
|
return targets;
|
|
}
|
|
|
|
bool BaseCombatAIComponent::IsMech() {
|
|
switch (m_Parent->GetLOT()) {
|
|
case 6253:
|
|
return true;
|
|
|
|
default:
|
|
return false;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
|
|
void BaseCombatAIComponent::Serialize(RakNet::BitStream& outBitStream, bool bIsInitialUpdate) {
|
|
outBitStream.Write(m_DirtyStateOrTarget || bIsInitialUpdate);
|
|
if (m_DirtyStateOrTarget || bIsInitialUpdate) {
|
|
outBitStream.Write(m_State);
|
|
outBitStream.Write(m_Target);
|
|
m_DirtyStateOrTarget = false;
|
|
}
|
|
}
|
|
|
|
void BaseCombatAIComponent::SetAiState(AiState newState) {
|
|
if (newState == this->m_State) return;
|
|
GameMessages::NotifyCombatAIStateChange stateMsg;
|
|
stateMsg.prevState = this->m_State;
|
|
stateMsg.newState = newState;
|
|
m_Parent->HandleMsg(stateMsg);
|
|
this->m_State = newState;
|
|
m_DirtyStateOrTarget = true;
|
|
Game::entityManager->SerializeEntity(m_Parent);
|
|
}
|
|
|
|
bool BaseCombatAIComponent::IsEnemy(LWOOBJID target) const {
|
|
auto* entity = Game::entityManager->GetEntity(target);
|
|
|
|
if (entity == nullptr) {
|
|
LOG("Invalid entity for checking validity (%llu)!", target);
|
|
|
|
return false;
|
|
}
|
|
|
|
auto* destroyable = entity->GetComponent<DestroyableComponent>();
|
|
|
|
if (destroyable == nullptr) {
|
|
return false;
|
|
}
|
|
|
|
auto* referenceDestroyable = m_Parent->GetComponent<DestroyableComponent>();
|
|
|
|
if (referenceDestroyable == nullptr) {
|
|
LOG("Invalid reference destroyable component on (%llu)!", m_Parent->GetObjectID());
|
|
|
|
return false;
|
|
}
|
|
|
|
auto* quickbuild = entity->GetComponent<QuickBuildComponent>();
|
|
|
|
if (quickbuild != nullptr) {
|
|
const auto state = quickbuild->GetState();
|
|
|
|
if (state != eQuickBuildState::COMPLETED) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
auto enemyList = referenceDestroyable->GetEnemyFactionsIDs();
|
|
|
|
auto candidateList = destroyable->GetFactionIDs();
|
|
|
|
for (auto value : candidateList) {
|
|
if (std::find(enemyList.begin(), enemyList.end(), value) != enemyList.end()) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
void BaseCombatAIComponent::SetTarget(const LWOOBJID target) {
|
|
if (this->m_Target == target) return;
|
|
m_Target = target;
|
|
m_DirtyStateOrTarget = true;
|
|
Game::entityManager->SerializeEntity(m_Parent);
|
|
}
|
|
|
|
Entity* BaseCombatAIComponent::GetTargetEntity() const {
|
|
return Game::entityManager->GetEntity(m_Target);
|
|
}
|
|
|
|
void BaseCombatAIComponent::Taunt(LWOOBJID offender, float threat) {
|
|
// Can't taunt self
|
|
if (offender == m_Parent->GetObjectID())
|
|
return;
|
|
|
|
m_ThreatEntries[offender] += threat;
|
|
m_DirtyThreat = true;
|
|
}
|
|
|
|
float BaseCombatAIComponent::GetThreat(LWOOBJID offender) {
|
|
const auto pair = m_ThreatEntries.find(offender);
|
|
|
|
if (pair == m_ThreatEntries.end()) return 0;
|
|
|
|
return pair->second;
|
|
}
|
|
|
|
void BaseCombatAIComponent::SetThreat(LWOOBJID offender, float threat) {
|
|
if (threat == 0) {
|
|
m_ThreatEntries.erase(offender);
|
|
} else {
|
|
m_ThreatEntries[offender] = threat;
|
|
}
|
|
|
|
m_DirtyThreat = true;
|
|
}
|
|
|
|
const NiPoint3& BaseCombatAIComponent::GetStartPosition() const {
|
|
return m_StartPosition;
|
|
}
|
|
|
|
void BaseCombatAIComponent::ClearThreat() {
|
|
m_ThreatEntries.clear();
|
|
m_Target = LWOOBJID_EMPTY;
|
|
|
|
m_DirtyThreat = true;
|
|
}
|
|
|
|
void BaseCombatAIComponent::Wander() {
|
|
if (!m_MovementAI->AtFinalWaypoint()) {
|
|
return;
|
|
}
|
|
|
|
// If we have a path to follow we should almost certainly do that instead of wandering.
|
|
if (m_MovementAI->HasPath()) {
|
|
return;
|
|
}
|
|
|
|
m_MovementAI->SetHaltDistance(0);
|
|
|
|
const auto& info = m_MovementAI->GetInfo();
|
|
|
|
const auto div = static_cast<int>(info.wanderDelayMax);
|
|
m_Timer = (div == 0 ? 0 : GeneralUtils::GenerateRandomNumber<int>(0, div)) + info.wanderDelayMin; //set a random timer to stay put.
|
|
|
|
const float radius = info.wanderRadius * sqrt(static_cast<double>(GeneralUtils::GenerateRandomNumber<float>(0, 1))); //our wander radius + a bit of random range
|
|
const float theta = ((static_cast<double>(GeneralUtils::GenerateRandomNumber<float>(0, 1)) * 2 * PI));
|
|
|
|
const NiPoint3 delta =
|
|
{
|
|
radius * cos(theta),
|
|
0,
|
|
radius * sin(theta)
|
|
};
|
|
|
|
auto destination = m_StartPosition + delta;
|
|
|
|
if (dpWorld::IsLoaded()) {
|
|
destination.y = dpWorld::GetNavMesh()->GetHeightAtPoint(destination);
|
|
}
|
|
|
|
if (Vector3::DistanceSquared(destination, m_MovementAI->GetParent()->GetPosition()) < 2 * 2) {
|
|
m_MovementAI->Stop();
|
|
|
|
return;
|
|
}
|
|
|
|
m_MovementAI->SetMaxSpeed(m_TetherSpeed);
|
|
|
|
m_MovementAI->SetDestination(destination);
|
|
|
|
m_Timer += (m_MovementAI->GetParent()->GetPosition().x - destination.x) / m_TetherSpeed;
|
|
}
|
|
|
|
void BaseCombatAIComponent::OnAggro() {
|
|
if (m_Target == LWOOBJID_EMPTY) return;
|
|
|
|
auto* target = GetTargetEntity();
|
|
|
|
if (target == nullptr) {
|
|
return;
|
|
}
|
|
|
|
m_MovementAI->SetHaltDistance(m_AttackRadius);
|
|
|
|
NiPoint3 targetPos = target->GetPosition();
|
|
NiPoint3 currentPos = m_MovementAI->GetParent()->GetPosition();
|
|
|
|
// If the player's position is within range, attack
|
|
if (Vector3::DistanceSquared(currentPos, targetPos) <= m_AttackRadius * m_AttackRadius) {
|
|
m_MovementAI->Stop();
|
|
} else if (Vector3::DistanceSquared(m_StartPosition, targetPos) > m_HardTetherRadius * m_HardTetherRadius) //Return to spawn if we're too far
|
|
{
|
|
m_MovementAI->SetMaxSpeed(m_PursuitSpeed);
|
|
|
|
m_MovementAI->SetDestination(m_StartPosition);
|
|
} else //Chase the player's new position
|
|
{
|
|
if (IsMech() && Vector3::DistanceSquared(targetPos, currentPos) > m_AttackRadius * m_AttackRadius * 3 * 3) return;
|
|
|
|
m_MovementAI->SetMaxSpeed(m_PursuitSpeed);
|
|
|
|
m_MovementAI->SetDestination(targetPos);
|
|
|
|
SetAiState(AiState::tether);
|
|
}
|
|
|
|
m_Timer += 0.5f;
|
|
}
|
|
|
|
void BaseCombatAIComponent::OnTether() {
|
|
auto* target = GetTargetEntity();
|
|
|
|
if (target == nullptr) {
|
|
return;
|
|
}
|
|
|
|
m_MovementAI->SetHaltDistance(m_AttackRadius);
|
|
|
|
NiPoint3 targetPos = target->GetPosition();
|
|
NiPoint3 currentPos = m_MovementAI->ApproximateLocation();
|
|
|
|
if (Vector3::DistanceSquared(currentPos, targetPos) <= m_AttackRadius * m_AttackRadius) {
|
|
m_MovementAI->Stop();
|
|
} else if (Vector3::DistanceSquared(m_StartPosition, targetPos) > m_HardTetherRadius * m_HardTetherRadius) //Return to spawn if we're too far
|
|
{
|
|
m_MovementAI->SetMaxSpeed(m_PursuitSpeed);
|
|
|
|
m_MovementAI->SetDestination(m_StartPosition);
|
|
|
|
SetAiState(AiState::aggro);
|
|
} else {
|
|
if (IsMech() && Vector3::DistanceSquared(targetPos, currentPos) > m_AttackRadius * m_AttackRadius * 3 * 3) return;
|
|
|
|
m_MovementAI->SetMaxSpeed(m_PursuitSpeed);
|
|
|
|
m_MovementAI->SetDestination(targetPos);
|
|
}
|
|
|
|
m_Timer += 0.5f;
|
|
}
|
|
|
|
bool BaseCombatAIComponent::GetStunned() const {
|
|
return m_Stunned;
|
|
}
|
|
|
|
void BaseCombatAIComponent::SetStunned(const bool value) {
|
|
m_Stunned = value;
|
|
}
|
|
|
|
bool BaseCombatAIComponent::GetStunImmune() const {
|
|
return m_StunImmune;
|
|
}
|
|
|
|
void BaseCombatAIComponent::SetStunImmune(bool value) {
|
|
m_StunImmune = value;
|
|
}
|
|
|
|
float BaseCombatAIComponent::GetTetherSpeed() const {
|
|
return m_TetherSpeed;
|
|
}
|
|
|
|
void BaseCombatAIComponent::SetTetherSpeed(float value) {
|
|
m_TetherSpeed = value;
|
|
}
|
|
|
|
void BaseCombatAIComponent::Stun(const float time, const bool force) {
|
|
if (!force && (m_StunImmune || m_StunTime > time)) {
|
|
return;
|
|
}
|
|
|
|
m_StunTime = time;
|
|
|
|
m_Stunned = true;
|
|
}
|
|
|
|
float BaseCombatAIComponent::GetAggroRadius() const {
|
|
return m_AggroRadius;
|
|
}
|
|
|
|
void BaseCombatAIComponent::SetAggroRadius(const float value) {
|
|
m_AggroRadius = value;
|
|
}
|
|
|
|
void BaseCombatAIComponent::LookAt(const NiPoint3& point) {
|
|
if (m_Stunned) {
|
|
return;
|
|
}
|
|
|
|
m_Parent->SetRotation(QuatUtils::LookAt(m_Parent->GetPosition(), point));
|
|
}
|
|
|
|
void BaseCombatAIComponent::SetDisabled(bool value) {
|
|
m_Disabled = value;
|
|
}
|
|
|
|
bool BaseCombatAIComponent::GetDistabled() const {
|
|
return m_Disabled;
|
|
}
|
|
|
|
void BaseCombatAIComponent::Sleep() {
|
|
m_dpEntity->SetSleeping(true);
|
|
m_dpEntityEnemy->SetSleeping(true);
|
|
}
|
|
|
|
void BaseCombatAIComponent::Wake() {
|
|
m_dpEntity->SetSleeping(false);
|
|
m_dpEntityEnemy->SetSleeping(false);
|
|
}
|
|
|
|
void BaseCombatAIComponent::TetherLogic() {
|
|
auto* destroyableComponent = m_Parent->GetComponent<DestroyableComponent>();
|
|
|
|
const bool applyTetherEffect = destroyableComponent != nullptr && destroyableComponent->HasFaction(4);
|
|
|
|
if (applyTetherEffect) {
|
|
auto serilizationRequired = false;
|
|
|
|
if (destroyableComponent->GetHealth() != destroyableComponent->GetMaxHealth()) {
|
|
destroyableComponent->SetHealth(destroyableComponent->GetMaxHealth());
|
|
|
|
serilizationRequired = true;
|
|
}
|
|
|
|
if (destroyableComponent->GetArmor() != destroyableComponent->GetMaxArmor()) {
|
|
destroyableComponent->SetArmor(destroyableComponent->GetMaxArmor());
|
|
|
|
serilizationRequired = true;
|
|
}
|
|
|
|
if (serilizationRequired) {
|
|
Game::entityManager->SerializeEntity(m_Parent);
|
|
}
|
|
|
|
GameMessages::PlayFXEffect(m_Parent->GetObjectID(), 6270, u"tether", "tether").Send(UNASSIGNED_SYSTEM_ADDRESS);
|
|
|
|
m_TetherEffectActive = true;
|
|
}
|
|
|
|
// Speed towards start position
|
|
if (m_MovementAI != nullptr) {
|
|
m_MovementAI->SetHaltDistance(0);
|
|
m_MovementAI->SetMaxSpeed(m_PursuitSpeed);
|
|
m_MovementAI->SetCurrentSpeed(m_PursuitSpeed);
|
|
m_MovementAI->SetDestination(m_StartPosition);
|
|
}
|
|
|
|
if (applyTetherEffect) {
|
|
// Use the actual navmesh path length (populated by SetDestination above) instead of the
|
|
// straight-line distance, otherwise the tether timer expires before the entity can walk
|
|
// around obstacles back to its spawn point.
|
|
const auto distance = m_MovementAI != nullptr
|
|
? m_MovementAI->GetRemainingPathDistance()
|
|
: NiPoint3::Distance(m_Parent->GetPosition(), m_StartPosition);
|
|
const auto speed = m_PursuitSpeed * MovementAIComponent::GetBaseSpeed(m_Parent->GetLOT());
|
|
m_TetherTime = speed > 0.0f ? distance / speed : 0.0f;
|
|
m_ForcedTetherTime = m_TetherTime;
|
|
}
|
|
}
|
|
|
|
void BaseCombatAIComponent::ForceTether() {
|
|
SetTarget(LWOOBJID_EMPTY);
|
|
m_ThreatEntries.clear();
|
|
TetherLogic();
|
|
m_ForcedTetherTime = m_TetherTime;
|
|
|
|
SetAiState(AiState::aggro);
|
|
}
|
|
|
|
void BaseCombatAIComponent::IgnoreThreat(const LWOOBJID threat, const float value) {
|
|
m_RemovedThreatList[threat] = value;
|
|
SetThreat(threat, 0.0f);
|
|
m_Target = LWOOBJID_EMPTY;
|
|
}
|
|
|
|
bool BaseCombatAIComponent::MsgGetObjectReportInfo(GameMessages::GetObjectReportInfo& reportInfo) {
|
|
using enum AiState;
|
|
auto& cmptType = reportInfo.info->PushDebug("Base Combat AI");
|
|
cmptType.PushDebug<AMFIntValue>("Component ID") = GetComponentID();
|
|
auto& targetInfo = cmptType.PushDebug("Current Target Info");
|
|
targetInfo.PushDebug<AMFStringValue>("Current Target ID", "LWOOBJID") = std::to_string(m_Target);
|
|
// if (m_Target != LWOOBJID_EMPTY) {
|
|
// LWOGameMessages::ObjGetName nameMsg(m_CurrentTarget);
|
|
// SEND_GAMEOBJ_MSG(nameMsg);
|
|
// if (!nameMsg.msg.name.empty()) targetInfo.PushDebug("Name") = nameMsg.msg.name;
|
|
// }
|
|
|
|
auto& roundInfo = cmptType.PushDebug("Round Info");
|
|
// roundInfo.PushDebug<AMFDoubleValue>("Combat Round Time") = m_CombatRoundLength;
|
|
// roundInfo.PushDebug<AMFDoubleValue>("Minimum Time") = m_MinRoundLength;
|
|
// roundInfo.PushDebug<AMFDoubleValue>("Maximum Time") = m_MaxRoundLength;
|
|
// roundInfo.PushDebug<AMFDoubleValue>("Selected Time") = m_SelectedTime;
|
|
// roundInfo.PushDebug<AMFDoubleValue>("Combat Start Delay") = m_CombatStartDelay;
|
|
std::string curState;
|
|
switch (m_State) {
|
|
case idle: curState = "Idling"; break;
|
|
case aggro: curState = "Aggroed"; break;
|
|
case tether: curState = "Returning to Tether"; break;
|
|
case spawn: curState = "Spawn"; break;
|
|
case dead: curState = "Dead"; break;
|
|
default: curState = "Unknown or Undefined"; break;
|
|
}
|
|
cmptType.PushDebug<AMFStringValue>("Current Combat State") = curState;
|
|
|
|
//switch (m_CombatBehaviorType) {
|
|
// case 0: curState = "Passive"; break;
|
|
// case 1: curState = "Aggressive"; break;
|
|
// case 2: curState = "Passive (Turret)"; break;
|
|
// case 3: curState = "Aggressive (Turret)"; break;
|
|
// default: curState = "Unknown or Undefined"; break;
|
|
//}
|
|
//cmptType.PushDebug("Current Combat Behavior State") = curState;
|
|
|
|
//switch (m_CombatRole) {
|
|
// case 0: curState = "Melee"; break;
|
|
// case 1: curState = "Ranged"; break;
|
|
// case 2: curState = "Support"; break;
|
|
// default: curState = "Unknown or Undefined"; break;
|
|
//}
|
|
//cmptType.PushDebug("Current Combat Role") = curState;
|
|
|
|
cmptType.PushDebug("Tether Point").PushDebug(m_StartPosition);
|
|
cmptType.PushDebug<AMFDoubleValue>("Hard Tether Radius") = m_HardTetherRadius;
|
|
cmptType.PushDebug<AMFDoubleValue>("Soft Tether Radius") = m_SoftTetherRadius;
|
|
cmptType.PushDebug<AMFDoubleValue>("Aggro Radius") = m_AggroRadius;
|
|
cmptType.PushDebug<AMFDoubleValue>("Tether Speed") = m_TetherSpeed;
|
|
cmptType.PushDebug<AMFDoubleValue>("Aggro Speed") = m_TetherSpeed;
|
|
// cmptType.PushDebug<AMFDoubleValue>("Specified Min Range") = m_SpecificMinRange;
|
|
// cmptType.PushDebug<AMFDoubleValue>("Specified Max Range") = m_SpecificMaxRange;
|
|
auto& threats = cmptType.PushDebug("Target Threats");
|
|
for (const auto& [id, threat] : m_ThreatEntries) {
|
|
threats.PushDebug<AMFDoubleValue>(std::to_string(id)) = threat;
|
|
}
|
|
|
|
auto& ignoredThreats = cmptType.PushDebug("Temp Ignored Threats");
|
|
for (const auto& [id, threat] : m_RemovedThreatList) {
|
|
ignoredThreats.PushDebug<AMFDoubleValue>(std::to_string(id) + " - Time") = threat;
|
|
}
|
|
auto& skillInfo = cmptType.PushDebug("Skill Info");
|
|
for (const auto& skill : m_SkillEntries) {
|
|
auto& skillDebug = skillInfo.PushDebug("Skill ID " + std::to_string(skill.skillId));
|
|
skillDebug.PushDebug<AMFDoubleValue>("Cooldown") = skill.cooldown;
|
|
skillDebug.PushDebug<AMFDoubleValue>("Ability Cooldown") = skill.abilityCooldown;
|
|
skillDebug.PushDebug<AMFIntValue>("AI Combat Weight") = skill.combatWeight;
|
|
}
|
|
|
|
return true;
|
|
}
|