feat(combat): TacArc picks its targets in the client's order

The client's TacArcBehavior::Cast (0x00fb2d10) sorts the targets in the arc nearest first, or by weight when
distance_weight or angle_weight is set (sortWithWeights, 0x00f58cd0: distance_weight * (max range - distance) /
max range + angle_weight * (180 - angle) / 180, heaviest first). With use_attack_priority, SortByAttackPriority
(0x00f72900) then buckets them by GetAttackPriority, lowest first, keeping that order inside each bucket; only the
DestroyableComponent answers it, and an object without one counts as 1. DoHit keeps the first max targets.

Nothing else ranks targets: enemies are taken over nearer smashables only because their attack_priority (1) is
lower than most smashables' (10). use_attack_priority is off when a behavior does not set it (TacArcBehavior::
Initialize, 0x00f9b980), as before.

The server sorted by distance only and ignored the flag, so a one-target swing hit the nearest crate instead of the
enemy behind it. OrderTargets now does the client's ordering, with equal targets in ascending id order (the order
the client's id set hands them over in); the chosen targets are still written in ascending id order (issue 1045).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-29 21:44:17 -05:00
parent e017c11aa2
commit 94b411a32e
3 changed files with 185 additions and 33 deletions

View File

@@ -9,6 +9,9 @@
#include "QuickBuildComponent.h"
#include "DestroyableComponent.h"
#include <algorithm>
#include <cmath>
#include <functional>
#include <set>
#include <vector>
@@ -89,10 +92,10 @@ bool TacArcBehavior::ReadTargets(RakNet::BitStream& bitStream, const uint32_t ma
return true;
}
std::set<LWOOBJID> TacArcBehavior::WriteTargets(RakNet::BitStream& bitStream, const std::vector<LWOOBJID>& closestFirst, const uint32_t maxTargets) {
// TacArcBehavior::DoHit (0x00fb10c0): the closest maxTargets targets, written and acted on in ascending id order
std::set<LWOOBJID> TacArcBehavior::WriteTargets(RakNet::BitStream& bitStream, const std::vector<LWOOBJID>& ordered, const uint32_t maxTargets) {
// TacArcBehavior::DoHit (0x00fb10c0): the first maxTargets targets, written and acted on in ascending id order
std::set<LWOOBJID> targets;
for (const auto target : closestFirst) {
for (const auto target : ordered) {
if (targets.size() >= maxTargets) break;
if (target != LWOOBJID_EMPTY) targets.insert(target);
}
@@ -102,6 +105,34 @@ std::set<LWOOBJID> TacArcBehavior::WriteTargets(RakNet::BitStream& bitStream, co
return targets;
}
std::vector<LWOOBJID> TacArcBehavior::OrderTargets(std::vector<Candidate> candidates, const float distanceWeight, const float angleWeight, const float maxRange, const bool useAttackPriority) {
// GetObjectsInsideTacArc hands the targets over in a set, so they start in ascending id order
std::ranges::sort(candidates, std::less{}, &Candidate::id);
// TacArcBehavior::Cast (0x00fb2d10) sorts by distance unless a weight is set
if (distanceWeight == 0.0f && angleWeight == 0.0f) {
std::ranges::stable_sort(candidates, std::less{}, &Candidate::distance);
} else {
// TacArcBehavior::sortWithWeights (0x00f58cd0): nearer and more straight ahead weighs more, heaviest first
const auto weight = [=](const Candidate& candidate) {
const auto distanceScore = maxRange > 0.0f ? (maxRange - candidate.distance) / maxRange : 0.0f;
const auto angleScore = std::abs(candidate.angle - 180.0f) / 180.0f;
return distanceWeight * distanceScore + angleWeight * angleScore;
};
std::ranges::stable_sort(candidates, std::greater{}, weight);
}
// TacArcBehavior::SortByAttackPriority (0x00f72900): buckets by GetAttackPriority, lowest first, each bucket in
// the order above. Nothing else ranks targets: enemies come before smashables because the enemies'
// attack_priority (1) is lower than most smashables' (10).
if (useAttackPriority) std::ranges::stable_sort(candidates, std::less{}, &Candidate::attackPriority);
std::vector<LWOOBJID> ordered;
ordered.reserve(candidates.size());
for (const auto& candidate : candidates) ordered.push_back(candidate.id);
return ordered;
}
void TacArcBehavior::Calculate(BehaviorContext* context, RakNet::BitStream& bitStream, BehaviorBranchContext branch) {
auto* self = Game::entityManager->GetEntity(context->originator);
if (self == nullptr) {
@@ -130,6 +161,7 @@ void TacArcBehavior::Calculate(BehaviorContext* context, RakNet::BitStream& bitS
auto reference = self->GetPosition() + m_offset;
targets.clear();
std::vector<Candidate> candidates;
std::vector<Entity*> validTargets = Game::entityManager->GetEntitiesByProximity(reference, this->m_maxRange);
@@ -165,32 +197,21 @@ void TacArcBehavior::Calculate(BehaviorContext* context, RakNet::BitStream& bitS
const float degreeAngle = std::abs(Vector3::Angle(forward, normalized) * (180 / 3.14) - 180);
if (distance >= this->m_minRange && this->m_maxRange >= distance && degreeAngle <= 2 * this->m_angle) {
targets.push_back(validTarget);
const auto* destroyable = validTarget->GetComponent<DestroyableComponent>();
candidates.push_back({
.id = validTarget->GetObjectID(),
.distance = distance,
.angle = degreeAngle,
// An object that does not answer GetAttackPriority keeps the message's default of 1
.attackPriority = destroyable ? destroyable->GetAttackPriority() : 1,
});
}
}
std::sort(targets.begin(), targets.end(), [this, reference, combatAi](Entity* a, Entity* b) {
const auto aDistance = Vector3::DistanceSquared(reference, a->GetPosition());
const auto bDistance = Vector3::DistanceSquared(reference, b->GetPosition());
return aDistance < bDistance;
});
if (m_useAttackPriority) {
// this should be using the attack priority column on the destroyable component
// We want targets with no threat level to remain the same order as above
// std::stable_sort(targets.begin(), targets.end(), [combatAi](Entity* a, Entity* b) {
// const auto aThreat = combatAi->GetThreat(a->GetObjectID());
// const auto bThreat = combatAi->GetThreat(b->GetObjectID());
// If enabled for this behavior, prioritize threat over distance
// return aThreat > bThreat;
// });
}
// After we've sorted and found our closest targets, size the vector down in case there are too many
if (m_maxTargets > 0 && targets.size() > m_maxTargets) targets.resize(m_maxTargets);
const auto hit = !targets.empty();
// The client keeps the first max targets of this order
auto ordered = OrderTargets(std::move(candidates), m_distanceWeight, m_angleWeight, m_maxRange, m_useAttackPriority);
if (m_maxTargets > 0 && ordered.size() > m_maxTargets) ordered.resize(m_maxTargets);
const auto hit = !ordered.empty();
bitStream.Write(hit);
if (this->m_checkEnv) {
@@ -199,13 +220,12 @@ void TacArcBehavior::Calculate(BehaviorContext* context, RakNet::BitStream& bitS
}
if (hit) {
if (combatAi) combatAi->LookAt(targets[0]->GetPosition());
const auto* first = Game::entityManager->GetEntity(ordered.front());
if (combatAi && first) combatAi->LookAt(first->GetPosition());
context->foundTarget = true; // We want to continue with this behavior
std::vector<LWOOBJID> closestFirst;
for (const auto* target : targets) closestFirst.push_back(target->GetObjectID());
for (const auto target : WriteTargets(bitStream, closestFirst, this->m_maxTargets)) {
for (const auto target : WriteTargets(bitStream, ordered, this->m_maxTargets)) {
branch.target = target;
this->m_action->Calculate(context, bitStream, branch);
}
@@ -236,6 +256,7 @@ void TacArcBehavior::Load() {
this->m_usePickedTarget = GetBoolean("use_picked_target", false);
this->m_useTargetPostion = GetBoolean("use_target_position", false);
this->m_checkEnv = GetBoolean("check_env", false);
// TacArcBehavior::Initialize (0x00f9b980): off unless the behavior sets it
this->m_useAttackPriority = GetBoolean("use_attack_priority", false);
this->m_action = GetAction("action");

View File

@@ -13,13 +13,29 @@ public:
void Calculate(BehaviorContext* context, RakNet::BitStream& bitStream, BehaviorBranchContext branch) override;
void Load() override;
// use_attack_priority, off when the behavior does not set it
bool UsesAttackPriority() const { return m_useAttackPriority; }
// Reads the target count and ids the way the client writes them: at most maxTargets, returned ascending and
// without empty ids. False when the data is cut short or lists too many targets.
static bool ReadTargets(RakNet::BitStream& bitStream, uint32_t maxTargets, std::set<LWOOBJID>& targets);
// Writes the closest maxTargets of closestFirst as the client does and returns them in the order their action
// data follows (ascending id)
static std::set<LWOOBJID> WriteTargets(RakNet::BitStream& bitStream, const std::vector<LWOOBJID>& closestFirst, uint32_t maxTargets);
// Writes the first maxTargets of ordered (see OrderTargets) as the client does and returns them in the order
// their action data follows (ascending id)
static std::set<LWOOBJID> WriteTargets(RakNet::BitStream& bitStream, const std::vector<LWOOBJID>& ordered, uint32_t maxTargets);
// A target in the arc and what the client orders it by
struct Candidate {
LWOOBJID id = LWOOBJID_EMPTY;
float distance = 0.0f;
float angle = 0.0f; // degrees from the caster's forward, 0 straight ahead
int32_t attackPriority = 1;
};
// Orders candidates the way the client does before it keeps the first max targets: nearest first, or highest
// weight first when distance_weight or angle_weight is set; then, with use_attack_priority, lower attack priority
// first, keeping that order within each priority. Equal candidates stay in ascending id order.
static std::vector<LWOOBJID> OrderTargets(std::vector<Candidate> candidates, float distanceWeight, float angleWeight, float maxRange, bool useAttackPriority);
private:
float m_maxRange;
float m_height;