From c64c6763a5fac60b35953f67cd27787ca7db9634 Mon Sep 17 00:00:00 2001 From: Atrik Date: Thu, 28 May 2026 16:27:45 +0200 Subject: [PATCH] Enable attacks on foes visible through shared LOS Adds a baseRange parameter to parabolic queries providing simple 2D detection alongside parabolic detection. StandGround and Chase stances now use full attack parabolic range with vision range as baseRange, allowing units to attack enemies visible through friendly vision. --- .../simulation/components/BuildingAI.js | 32 +++++--- .../public/simulation/components/UnitAI.js | 56 +++++++++++--- .../components/CCmpRangeManager.cpp | 74 ++++++++++++++++--- .../simulation2/components/ICmpRangeManager.h | 6 +- 4 files changed, 131 insertions(+), 37 deletions(-) diff --git a/binaries/data/mods/public/simulation/components/BuildingAI.js b/binaries/data/mods/public/simulation/components/BuildingAI.js index 929bb83feb..ff218d9ae9 100644 --- a/binaries/data/mods/public/simulation/components/BuildingAI.js +++ b/binaries/data/mods/public/simulation/components/BuildingAI.js @@ -127,9 +127,17 @@ BuildingAI.prototype.SetupRangeQuery = function() const range = cmpAttack.GetRange(attackType); const yOrigin = cmpAttack.GetAttackYOrigin(attackType); + + // Get building's vision range + const cmpVision = Engine.QueryInterface(this.entity, IID_Vision); + const visionRange = cmpVision ? cmpVision.GetRange() : 0; + + // Base range + const baseRange = Math.min(visionRange, range.max); + // This takes entity sizes into accounts, so no need to compensate for structure size. this.enemyUnitsQuery = cmpRangeManager.CreateActiveParabolicQuery( - this.entity, range.min, range.max, yOrigin, + this.entity, range.min, range.max, baseRange, yOrigin, enemies, IID_Resistance, cmpRangeManager.GetEntityFlagMask("normal")); cmpRangeManager.EnableActiveQuery(this.enemyUnitsQuery); @@ -156,10 +164,17 @@ BuildingAI.prototype.SetupGaiaRangeQuery = function() const range = cmpAttack.GetRange(attackType); const yOrigin = cmpAttack.GetAttackYOrigin(attackType); + // Get building's vision range + const cmpVision = Engine.QueryInterface(this.entity, IID_Vision); + const visionRange = cmpVision ? cmpVision.GetRange() : 0; + + // Base range + const baseRange = Math.min(visionRange, range.max); + // This query is only interested in Gaia entities that can attack. // This takes entity sizes into accounts, so no need to compensate for structure size. this.gaiaUnitsQuery = cmpRangeManager.CreateActiveParabolicQuery( - this.entity, range.min, range.max, yOrigin, + this.entity, range.min, range.max, baseRange, yOrigin, [0], IID_Attack, cmpRangeManager.GetEntityFlagMask("normal")); cmpRangeManager.EnableActiveQuery(this.gaiaUnitsQuery); @@ -170,7 +185,6 @@ BuildingAI.prototype.SetupGaiaRangeQuery = function() */ BuildingAI.prototype.OnRangeUpdate = function(msg) { - var cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); if (!cmpAttack) return; @@ -189,10 +203,10 @@ BuildingAI.prototype.OnRangeUpdate = function(msg) // Add new targets. for (const entity of msg.added) - if (cmpAttack.CanAttack(entity)) + if (!this.targetUnits.includes(entity)) this.targetUnits.push(entity); - // Remove targets outside of vision-range. + // Remove targets out of range. for (const entity of msg.removed) { const index = this.targetUnits.indexOf(entity); @@ -375,13 +389,7 @@ BuildingAI.prototype.FireArrows = function() { const selectedTarget = targets[targetIndex].entityId; - if (this.CheckTargetVisible(selectedTarget) && cmpObstructionManager.IsInTargetParabolicRange( - this.entity, - selectedTarget, - range.min, - range.max, - yOrigin, - false)) + if (cmpAttack.CanAttack(selectedTarget, [attackType])) { cmpAttack.PerformAttack(attackType, selectedTarget); PlaySound("attack_" + attackType.toLowerCase(), this.entity); diff --git a/binaries/data/mods/public/simulation/components/UnitAI.js b/binaries/data/mods/public/simulation/components/UnitAI.js index 8433ba7c59..1e22e2ba39 100644 --- a/binaries/data/mods/public/simulation/components/UnitAI.js +++ b/binaries/data/mods/public/simulation/components/UnitAI.js @@ -4053,10 +4053,17 @@ UnitAI.prototype.SetupAttackRangeQuery = function(enable = true) { const cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); const yOrigin = cmpAttack ? cmpAttack.GetAttackYOrigin("Ranged") : 0; + const baseRange = range.baseRange; + // Do not compensate for entity sizes: LOS doesn't, and UnitAI relies on that. - this.losAttackRangeQuery = cmpRangeManager.CreateActiveParabolicQuery(this.entity, - range.min, range.max, yOrigin, - players, IID_Resistance, + this.losAttackRangeQuery = cmpRangeManager.CreateActiveParabolicQuery( + this.entity, + range.min, + range.max, + baseRange, + yOrigin, + players, + IID_Resistance, cmpRangeManager.GetEntityFlagMask("normal")); } else @@ -5383,8 +5390,16 @@ UnitAI.prototype.ShouldChaseTargetedEntity = function(target, force) if (!this.AbleToMove()) return false; + // Check if we should chase based on stance if (this.GetStance().respondChase) - return true; + { + // If we're allowed to chase beyond vision, always chase + if (this.GetStance().respondChaseBeyondVision) + return true; + + // Otherwise, only chase if the target is within our personal vision + return this.CheckTargetIsInVisionRange(target); + } // If we are guarding/escorting, chase at least as long as the guarded unit is in target range of the attacker if (this.isGuardOf) @@ -6357,14 +6372,15 @@ UnitAI.prototype.FindWalkAndFightTargets = function() * the unit should "notice" an enemy and potentially start moving toward it. * * @param {number} iid - IID_Vision, IID_Heal, or IID_Attack - * @returns {{min: number, max: number, parabolic: boolean}} + * @returns {{min: number, max: number, baseRange: number, parabolic: boolean}} * 'parabolic' indicates that the caller * should use a parabolic range query (accounting for elevation) instead of a * flat 2D one. Generally used for projectile attacks. + * 'baseRange' is a non-parabolic 2D detection range that always counts as in-range. */ UnitAI.prototype.GetQueryRange = function(iid) { - const ret = { "min": 0, "max": 0, "parabolic": false }; + const ret = { "min": 0, "max": 0, "baseRange": 0, "parabolic": false }; const cmpVision = Engine.QueryInterface(this.entity, IID_Vision); if (!cmpVision) @@ -6386,7 +6402,8 @@ UnitAI.prototype.GetQueryRange = function(iid) if (!range) return ret; ret.min = range.min; - ret.max = Math.min(range.max, visionRange); + ret.max = range.max; + ret.baseRange = Math.min(visionRange, range.max); // For StandGround, the 'parabolic' flag is set so that the caller can create a // parabolic query instead of a flat one. This ensures elevation bonuses are // properly accounted for when detecting enemies. @@ -6397,12 +6414,27 @@ UnitAI.prototype.GetQueryRange = function(iid) // before actually attacking. ret.parabolic = range.parabolic; } - // In other stances, don't make the range parabolic, since they use vision/approach ranges - // that are larger than attack range, so targets will be spotted as they chase/approach anyway. - // TODO: With large height differences, the effective parabolic attack range can exceed - // vision/approach ranges, causing targets to be spotted later than ideal. + // In other stances, detect enemies within vision or within parabolic attack range. + // This allows attacking enemies visible through allies but outside personal vision. + // Base range capped by attack range to avoid querying beyond what we can actually hit. else if (this.GetStance().respondChase) - ret.max = visionRange; + { + const range = this.GetRange(iid); + if (range && range.parabolic) + { + // Detect enemies within vision and + // enemies within parabolic attack range + ret.parabolic = true; + ret.max = range.max; // Parabolic attack range + ret.baseRange = Math.min(visionRange, range.max); // Always detect within vision + ret.min = range.min; + } + else + { + // Non-parabolic attacks: simple vision range + ret.max = visionRange; + } + } else if (this.GetStance().respondHoldGround) { // HoldGround: vision range + half attack range (willing to move a bit) diff --git a/source/simulation2/components/CCmpRangeManager.cpp b/source/simulation2/components/CCmpRangeManager.cpp index 6df271f087..38505fe8cb 100644 --- a/source/simulation2/components/CCmpRangeManager.cpp +++ b/source/simulation2/components/CCmpRangeManager.cpp @@ -186,8 +186,10 @@ struct Query { std::vector lastMatch; CEntityHandle source; // TODO: this could crash if an entity is destroyed while a Query is still referencing it + player_id_t sourcePlayer; // Player who owns the source entity entity_pos_t minRange; entity_pos_t maxRange; + entity_pos_t baseRange; // Non-parabolic detection range entity_pos_t yOrigin; // Used for parabolas only. u32 ownersMask; i32 interface; @@ -318,8 +320,10 @@ struct SerializeHelper template void Common(S& serialize, const char* /*name*/, Serialize::qualify value) { + serialize.NumberI32_Unbounded("sourcePlayer", value.sourcePlayer); serialize.NumberFixed_Unbounded("min range", value.minRange); serialize.NumberFixed_Unbounded("max range", value.maxRange); + serialize.NumberFixed_Unbounded("baseRange", value.baseRange); serialize.NumberFixed_Unbounded("yOrigin", value.yOrigin); serialize.NumberU32_Unbounded("owners mask", value.ownersMask); serialize.NumberI32_Unbounded("interface", value.interface); @@ -972,12 +976,11 @@ public: } tag_t CreateActiveParabolicQuery(entity_id_t source, - entity_pos_t minRange, entity_pos_t maxRange, entity_pos_t yOrigin, + entity_pos_t minRange, entity_pos_t maxRange, entity_pos_t baseRange, entity_pos_t yOrigin, const std::vector& owners, int requiredInterface, u8 flags) override { tag_t id = m_QueryNext++; - m_Queries[id] = ConstructParabolicQuery(source, minRange, maxRange, yOrigin, owners, requiredInterface, flags, true); - + m_Queries[id] = ConstructParabolicQuery(source, minRange, maxRange, baseRange, yOrigin, owners, requiredInterface, flags, true); return id; } @@ -1268,9 +1271,33 @@ public: if (id == q.source.GetId()) return false; - // Ignore if it's missing the required interface + // Check if this is a mirage entity + CmpPtr cmpMirage(GetSimContext(), id); + bool isMirage = !!cmpMirage; + + // Ignore if it's missing the required interface but allow mirages to bypass if (q.interface && !GetSimContext().GetComponentManager().QueryInterface(id, q.interface)) - return false; + { + if (!isMirage) + return false; + } + + // Skip hidden entities (not visible to source player) + if (q.source.GetId() != INVALID_ENTITY) + { + // Look up the source's current owner + EntityMap::const_iterator itSource = m_EntityData.find(q.source.GetId()); + if (itSource != m_EntityData.end()) + { + player_id_t sourceOwner = itSource->second.owner; + if (sourceOwner != INVALID_PLAYER) + { + LosVisibility vis = GetPlayerVisibility(entity.visibilities, sourceOwner); + if (vis == LosVisibility::HIDDEN) + return false; + } + } + } return true; } @@ -1295,13 +1322,18 @@ public: // Not the entire world, so check a parabolic range, or a regular range. else if (q.parabolic) { - // The yOrigin is part of the 3D position, as the source is really that much heigher. + // The yOrigin is part of the 3D position, as the source is really that much higher. CmpPtr cmpSourcePosition(q.source); - CFixedVector3D pos3d = cmpSourcePosition->GetPosition()+ - CFixedVector3D(entity_pos_t::Zero(), q.yOrigin, entity_pos_t::Zero()) ; - // Get a quick list of entities that are potentially in range, with a cutoff of 2*maxRange. + CFixedVector3D pos3d = cmpSourcePosition->GetPosition() + + CFixedVector3D(entity_pos_t::Zero(), q.yOrigin, entity_pos_t::Zero()); + // Get a quick list of entities that are potentially in range. + // For parabolic queries, the search radius must cover: + // 1. The baseRange circle (non-parabolic detection) + // 2. The maximum possible horizontal extent of the parabolic range + // Multiplying maxRange by 2 provides a safe upper bound for all possible height differences. + entity_pos_t subdivisionRange = std::max(q.baseRange, q.maxRange * 2); subdivisionResultsBuffer.clear(); - m_Subdivision.GetNear(subdivisionResultsBuffer, pos, q.maxRange * 2); + m_Subdivision.GetNear(subdivisionResultsBuffer, pos, subdivisionRange); for (size_t i = 0; i < subdivisionResultsBuffer.size(); ++i) { @@ -1311,6 +1343,20 @@ public: if (!TestEntityQuery(q, it->first, it->second)) continue; + CFixedVector2D delta2D = CFixedVector2D(it->second.x, it->second.z) - pos; + + // Check base range first + bool inBaseRange = !q.baseRange.IsZero() && delta2D.CompareLength(q.baseRange) <= 0; + + if (inBaseRange) + { + // In base range - no need for parabolic check + if (q.minRange.IsZero() || delta2D.CompareLength(q.minRange) >= 0) + r.push_back(it->first); + continue; + } + + // Parabolic check for entities outside base range CmpPtr cmpSecondPosition(GetSimContext(), subdivisionResultsBuffer[i]); if (!cmpSecondPosition || !cmpSecondPosition->IsInWorld()) continue; @@ -1328,7 +1374,7 @@ public: continue; if (!q.minRange.IsZero()) - if ((CFixedVector2D(it->second.x, it->second.z) - pos).CompareLength(q.minRange) < 0) + if (delta2D.CompareLength(q.minRange) < 0) continue; r.push_back(it->first); @@ -1536,10 +1582,13 @@ public: if (maxRange < entity_pos_t::Zero() && maxRange != ALWAYS_IN_RANGE) LOGWARNING("CCmpRangeManager: Invalid max range %f in query for entity %u", maxRange.ToDouble(), source); + CmpPtr cmpOwnership(GetSimContext(), source); + Query q; q.enabled = false; q.parabolic = false; q.source = GetSimContext().GetComponentManager().LookupEntityHandle(source); + q.sourcePlayer = INVALID_PLAYER; q.minRange = minRange; q.maxRange = maxRange; q.yOrigin = entity_pos_t::Zero(); @@ -1580,12 +1629,13 @@ public: } Query ConstructParabolicQuery(entity_id_t source, - entity_pos_t minRange, entity_pos_t maxRange, entity_pos_t yOrigin, + entity_pos_t minRange, entity_pos_t maxRange, entity_pos_t baseRange, entity_pos_t yOrigin, const std::vector& owners, int requiredInterface, u8 flagsMask, bool accountForSize) const { Query q = ConstructQuery(source, minRange, maxRange, owners, requiredInterface, flagsMask, accountForSize); q.parabolic = true; q.yOrigin = yOrigin; + q.baseRange = baseRange; return q; } diff --git a/source/simulation2/components/ICmpRangeManager.h b/source/simulation2/components/ICmpRangeManager.h index 8ee4e0e1db..f70c830d32 100644 --- a/source/simulation2/components/ICmpRangeManager.h +++ b/source/simulation2/components/ICmpRangeManager.h @@ -174,6 +174,9 @@ public: * @param maxRange non-negative maximum distance in metres (inclusive) for units on the same elevation; * or -1.0 to ignore distance. * For units on a different height positions, a physical correct paraboloid with height=maxRange/2 above the unit is used to query them + * @param baseRange non-negative base detection range in metres (inclusive) for simple 2D circle checks. + * Units within this horizontal distance are always considered in range regardless of height. + * Set to 0 to disable (original parabolic-only behavior). * @param yOrigin extra bonus so the source can be placed higher and shoot further * @param owners list of player IDs that matching entities may have; -1 matches entities with no owner. * @param requiredInterface if non-zero, an interface ID that matching entities must implement. @@ -181,7 +184,8 @@ public: * NB: this one has no accountForSize parameter (assumed true), because we currently can only have 7 arguments for JS functions. * @return unique non-zero identifier of query. */ - virtual tag_t CreateActiveParabolicQuery(entity_id_t source, entity_pos_t minRange, entity_pos_t maxRange, entity_pos_t yOrigin, + virtual tag_t CreateActiveParabolicQuery(entity_id_t source, + entity_pos_t minRange, entity_pos_t maxRange, entity_pos_t baseRange, entity_pos_t yOrigin, const std::vector& owners, int requiredInterface, u8 flags) = 0;