mirror of
https://gitea.wildfiregames.com/0ad/0ad
synced 2026-06-16 05:13:58 -07:00
Prevent targeting unreachable turreted entities
Introduce CanEverReachTarget in the Attack component to check whether a target is geometrically reachable at all, accounting for height offsets and turreted units inside buildings. For non-parabolic attacks (e.g. melee), a simple 3D distance check from the closest approach point is used. For parabolic attacks (e.g. ranged), the parabolic range formula is used to determine if the height difference is surmountable from the closest horizontal distance to the target. Add GetClosestApproachDistanceToTurretPoint to TurretHolder to estimate the minimum horizontal distance to a turret point, using its local offset and the holder's obstruction size. Passable buildings are not considered obstacles. Fixes units on the ground trying to attack unreachable units on walls or towers when the projectile's arc cannot reach the required height.
This commit is contained in:
parent
8ba2b66514
commit
a19b8b8b50
8 changed files with 185 additions and 14 deletions
|
|
@ -277,11 +277,6 @@ Attack.prototype.CanAttack = function(target, wantedTypes)
|
|||
const cmpCapturable = QueryMiragedInterface(target, IID_Capturable);
|
||||
const cmpDiplomacy = QueryPlayerIDInterface(entityOwner, IID_Diplomacy);
|
||||
|
||||
// Check if the relative height difference is larger than the attack range
|
||||
// If the relative height is bigger, it means they will never be able to
|
||||
// reach each other, no matter how close they come.
|
||||
const heightDiff = Math.abs(cmpThisPosition.GetHeightOffset() - cmpTargetPosition.GetHeightOffset());
|
||||
|
||||
for (const type of types)
|
||||
{
|
||||
if (type != "Capture" && (!cmpDiplomacy?.IsEnemy(targetOwner) || !cmpHealth || !cmpHealth.GetHitpoints()))
|
||||
|
|
@ -290,7 +285,8 @@ Attack.prototype.CanAttack = function(target, wantedTypes)
|
|||
if (type == "Capture" && (!cmpCapturable || !cmpCapturable.CanCapture(entityOwner)))
|
||||
continue;
|
||||
|
||||
if (heightDiff > this.GetRange(type).max)
|
||||
// Check if the target is currently in range, or could ever be reached
|
||||
if (!this.IsTargetInRange(target, type) && !this.CanEverReachTarget(target, type))
|
||||
continue;
|
||||
|
||||
const restrictedClasses = this.GetRestrictedClasses(type);
|
||||
|
|
@ -304,6 +300,77 @@ Attack.prototype.CanAttack = function(target, wantedTypes)
|
|||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the target could potentially ever be reached with the given attack type,
|
||||
* as an optimistic estimate. This assumes the attacker can move to the closest
|
||||
* possible position to the target — ignoring obstructions and terrain features
|
||||
* (e.g., hills) that might help or hinder.
|
||||
*
|
||||
* This is a best-effort guess:
|
||||
* - It may return true even when the target is actually unreachable (e.g., turreted
|
||||
* units on walls with a height offset too large for the projectile to overcome).
|
||||
* - It may return false even when the target is reachable (e.g., a nearby hill could
|
||||
* provide enough elevation to hit a "too high" target, but we don't check for that).
|
||||
*
|
||||
* Currently these checks are mostly useful to determine if we can reach turreted units
|
||||
* (e.g. on a wall, outpost...).
|
||||
*
|
||||
* @param {number} targetId - The target entity ID.
|
||||
* @param {string} type - The attack type.
|
||||
* @return {boolean} - Whether the target is estimated to be reachable (see caveats above).
|
||||
*/
|
||||
Attack.prototype.CanEverReachTarget = function(targetId, type)
|
||||
{
|
||||
const cmpThisPosition = Engine.QueryInterface(this.entity, IID_Position);
|
||||
const cmpTargetPosition = Engine.QueryInterface(targetId, IID_Position);
|
||||
|
||||
const thisHeightOffset = cmpThisPosition.GetHeightOffset();
|
||||
const targetHeightOffset = cmpTargetPosition.GetHeightOffset();
|
||||
|
||||
const range = this.GetRange(type);
|
||||
|
||||
// Find the closest horizontal distance we could ever get to the target.
|
||||
// We first determine the closest horizontal distance we could ever get to the target,
|
||||
// accounting for turreted units inside buildings:
|
||||
// - If the building blocks movement, we can only reach its exterior edge.
|
||||
// - If the building is passable, we can walk right up to the turret point.
|
||||
const cmpTurretable = Engine.QueryInterface(targetId, IID_Turretable);
|
||||
const holderId = cmpTurretable?.HolderID();
|
||||
let closestDistance = 0;
|
||||
if (holderId && holderId != INVALID_ENTITY)
|
||||
{
|
||||
const cmpTurretHolder = Engine.QueryInterface(holderId, IID_TurretHolder);
|
||||
if (cmpTurretHolder)
|
||||
{
|
||||
const turretPoint = cmpTurretHolder.GetOccupiedTurretPoint(targetId);
|
||||
closestDistance = cmpTurretHolder.GetClosestApproachDistanceToTurretPoint(turretPoint);
|
||||
}
|
||||
}
|
||||
|
||||
if (!range.parabolic)
|
||||
{
|
||||
// For non-parabolic attacks (e.g., "Melee" attack type), we check if the height offset
|
||||
// is within max range at the closest possible horizontal distance (simple 3D distance check).
|
||||
const heightDiff = Math.abs(targetHeightOffset - thisHeightOffset);
|
||||
return Math.sqrt(closestDistance * closestDistance + heightDiff * heightDiff) <= range.max;
|
||||
}
|
||||
|
||||
// For parabolic attacks (generally "Ranged" attack type), we use the parabolic formula
|
||||
// to determine if the height offset is surmountable at the closest possible distance.
|
||||
// Typical scenario: units on walls/towers may be unreachable if the attacker's
|
||||
// projectiles can't arc high enough, even at point-blank range.
|
||||
const cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager);
|
||||
if (!cmpRangeManager)
|
||||
return true;
|
||||
|
||||
const yOrigin = this.GetAttackYOrigin(type);
|
||||
|
||||
const maxReachableHeightDiff = cmpRangeManager.GetMaxReachableParabolicHeight(
|
||||
range.max, yOrigin, closestDistance);
|
||||
|
||||
return targetHeightOffset - thisHeightOffset <= maxReachableHeightDiff;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns undefined if we have no preference or the lowest index of a preferred class.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -240,6 +240,39 @@ class TurretHolder
|
|||
return turret ? turret.name : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the closest horizontal distance an external entity could ever get
|
||||
* to the specified turret point. If the holder is passable, returns 0.
|
||||
* Otherwise returns the perpendicular distance from the turret point to the
|
||||
* nearest edge of the holder's obstruction.
|
||||
*
|
||||
* @param {string|Object} turretPoint - The turret point name or object.
|
||||
* @return {number} - The minimum possible horizontal distance.
|
||||
*/
|
||||
GetClosestApproachDistanceToTurretPoint(turretPoint)
|
||||
{
|
||||
if (typeof turretPoint === "string")
|
||||
turretPoint = this.TurretPointByName(turretPoint);
|
||||
if (!turretPoint)
|
||||
return 0;
|
||||
|
||||
const cmpObstruction = Engine.QueryInterface(this.entity, IID_Obstruction);
|
||||
if (!cmpObstruction || !cmpObstruction.GetBlockMovementFlag(false))
|
||||
return 0;
|
||||
|
||||
const dxLocal = turretPoint.offset.x;
|
||||
const dzLocal = turretPoint.offset.z;
|
||||
|
||||
const halfSizes = cmpObstruction.GetObstructionHalfSizes();
|
||||
const hw = halfSizes.x;
|
||||
const hh = halfSizes.y;
|
||||
|
||||
if (hw == null || hh == null || hw < 0 || hh < 0)
|
||||
return 0;
|
||||
|
||||
return Math.max(0, Math.min(hw - Math.abs(dxLocal), hh - Math.abs(dzLocal)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number[]} - The turretted entityIDs.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ Engine.LoadComponentScript("interfaces/Formation.js");
|
|||
Engine.LoadComponentScript("interfaces/Health.js");
|
||||
Engine.LoadComponentScript("interfaces/Resistance.js");
|
||||
Engine.LoadComponentScript("interfaces/TechnologyManager.js");
|
||||
Engine.LoadComponentScript("interfaces/Turretable.js");
|
||||
Engine.LoadComponentScript("interfaces/TurretHolder.js");
|
||||
Engine.LoadComponentScript("Attack.js");
|
||||
|
||||
let entityID = 903;
|
||||
|
|
@ -52,6 +54,16 @@ function attackComponentTest(defenderClass, isEnemy, test_function)
|
|||
"IsEnemy": () => isEnemy
|
||||
});
|
||||
|
||||
AddMock(SYSTEM_ENTITY, IID_ObstructionManager, {
|
||||
"IsInTargetRange": () => true
|
||||
});
|
||||
|
||||
AddMock(SYSTEM_ENTITY, IID_RangeManager, {
|
||||
"GetEffectiveParabolicRange": () => 25,
|
||||
"GetMaxReachableParabolicHeight": () => 15,
|
||||
"GetLosVisibility": (target, owner) => "visible"
|
||||
});
|
||||
|
||||
const attacker = entityID;
|
||||
|
||||
AddMock(attacker, IID_Position, {
|
||||
|
|
@ -416,3 +428,4 @@ function testAttackPreference()
|
|||
TS_ASSERT_EQUALS(cmpAttack.GetPreference(attacker+4), undefined);
|
||||
}
|
||||
testAttackPreference();
|
||||
|
||||
|
|
|
|||
|
|
@ -1365,6 +1365,22 @@ public:
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute effective horizontal range given a reference range and height difference.
|
||||
*/
|
||||
static entity_pos_t ComputeParabolicRange(entity_pos_t range, entity_pos_t heightDiff)
|
||||
{
|
||||
if (heightDiff < -range / 2)
|
||||
return NEVER_IN_RANGE;
|
||||
|
||||
entity_pos_t effectiveRange;
|
||||
effectiveRange.SetInternalValue(static_cast<i32>(isqrt64(
|
||||
SQUARE_U64_FIXED(range) +
|
||||
static_cast<i64>(heightDiff.GetInternalValue()) * static_cast<i64>(range.GetInternalValue()) * 2
|
||||
)));
|
||||
return effectiveRange;
|
||||
}
|
||||
|
||||
entity_pos_t GetEffectiveParabolicRange(entity_id_t source, entity_id_t target, entity_pos_t range, entity_pos_t yOrigin) const override
|
||||
{
|
||||
// For non-positive ranges, just return the range.
|
||||
|
|
@ -1383,14 +1399,28 @@ public:
|
|||
CFixedVector3D sourcePos = cmpSourcePosition->GetPosition();
|
||||
CFixedVector3D targetPos = cmpTargetPosition->GetPosition();
|
||||
|
||||
entity_pos_t heightDifference = sourcePos.Y - targetPos.Y + yOrigin;
|
||||
entity_pos_t heightDiff = sourcePos.Y - targetPos.Y + yOrigin;
|
||||
return ComputeParabolicRange(range, heightDiff);
|
||||
}
|
||||
|
||||
if (heightDifference < -range / 2)
|
||||
return NEVER_IN_RANGE;
|
||||
entity_pos_t GetMaxReachableParabolicHeight(entity_pos_t range, entity_pos_t yOrigin, entity_pos_t horizDistance) const override
|
||||
{
|
||||
// EffectiveRange² = range² + 2 * range * heightDiff
|
||||
// Solve for heightDiff when effectiveRange = horizDistance:
|
||||
// heightDiff = (horizDistance² - range²) / (2 * range)
|
||||
// Max target height above source = yOrigin - heightDiff
|
||||
// = yOrigin + (range² - horizDistance²) / (2 * range)
|
||||
//
|
||||
// If horizDistance > range, the result is less than yOrigin (can be negative),
|
||||
// meaning the source must be above the target to compensate for the extra horizontal distance.
|
||||
// The caller can decide if that's acceptable.
|
||||
i64 rangeSq = SQUARE_U64_FIXED(range);
|
||||
i64 distSq = SQUARE_U64_FIXED(horizDistance);
|
||||
i64 numerator = rangeSq - distSq;
|
||||
|
||||
entity_pos_t effectiveRange;
|
||||
effectiveRange.SetInternalValue(static_cast<i32>(isqrt64(SQUARE_U64_FIXED(range) + static_cast<i64>(heightDifference.GetInternalValue()) * static_cast<i64>(range.GetInternalValue()) * 2)));
|
||||
return effectiveRange;
|
||||
entity_pos_t result;
|
||||
result.SetInternalValue(static_cast<i32>(numerator / static_cast<i64>(range.GetInternalValue() * 2)));
|
||||
return yOrigin + result;
|
||||
}
|
||||
|
||||
entity_pos_t GetElevationAdaptedRange(const CFixedVector3D& pos1, const CFixedVector3D& rot, entity_pos_t range, entity_pos_t yOrigin, entity_pos_t angle) const override
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
/* Copyright (C) 2025 Wildfire Games.
|
||||
/* Copyright (C) 2026 Wildfire Games.
|
||||
* This file is part of 0 A.D.
|
||||
*
|
||||
* 0 A.D. is free software: you can redistribute it and/or modify
|
||||
|
|
@ -45,6 +45,14 @@ std::string ICmpObstruction::CheckFoundation_wrapper(const std::string& classNam
|
|||
}
|
||||
}
|
||||
|
||||
CFixedVector2D ICmpObstruction::GetObstructionHalfSizes_wrapper() const
|
||||
{
|
||||
ICmpObstructionManager::ObstructionSquare square;
|
||||
if (!GetObstructionSquare(square))
|
||||
return CFixedVector2D(entity_pos_t::FromInt(-1), entity_pos_t::FromInt(-1));
|
||||
return CFixedVector2D(square.hw, square.hh);
|
||||
}
|
||||
|
||||
BEGIN_INTERFACE_WRAPPER(Obstruction)
|
||||
DEFINE_INTERFACE_METHOD("GetSize", ICmpObstruction, GetSize)
|
||||
DEFINE_INTERFACE_METHOD("CheckShorePlacement", ICmpObstruction, CheckShorePlacement)
|
||||
|
|
@ -55,6 +63,7 @@ DEFINE_INTERFACE_METHOD("GetEntitiesBlockingConstruction", ICmpObstruction, GetE
|
|||
DEFINE_INTERFACE_METHOD("GetEntitiesDeletedUponConstruction", ICmpObstruction, GetEntitiesDeletedUponConstruction)
|
||||
DEFINE_INTERFACE_METHOD("SetActive", ICmpObstruction, SetActive)
|
||||
DEFINE_INTERFACE_METHOD("SetDisableBlockMovementPathfinding", ICmpObstruction, SetDisableBlockMovementPathfinding)
|
||||
DEFINE_INTERFACE_METHOD("GetObstructionHalfSizes", ICmpObstruction, GetObstructionHalfSizes_wrapper)
|
||||
DEFINE_INTERFACE_METHOD("GetBlockMovementFlag", ICmpObstruction, GetBlockMovementFlag)
|
||||
DEFINE_INTERFACE_METHOD("SetControlGroup", ICmpObstruction, SetControlGroup)
|
||||
DEFINE_INTERFACE_METHOD("GetControlGroup", ICmpObstruction, GetControlGroup)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
/* Copyright (C) 2025 Wildfire Games.
|
||||
/* Copyright (C) 2026 Wildfire Games.
|
||||
* This file is part of 0 A.D.
|
||||
*
|
||||
* 0 A.D. is free software: you can redistribute it and/or modify
|
||||
|
|
@ -104,6 +104,12 @@ public:
|
|||
*/
|
||||
virtual std::string CheckFoundation_wrapper(const std::string& className, bool onlyCenterPoint) const;
|
||||
|
||||
/**
|
||||
* GetObstructionSquare wrapper for script calls.
|
||||
* @return [hw, hh] half-sizes of the obstruction square, or empty array on failure.
|
||||
*/
|
||||
virtual CFixedVector2D GetObstructionHalfSizes_wrapper() const;
|
||||
|
||||
/**
|
||||
* Test whether this entity is colliding with any obstructions that share its
|
||||
* control groups and block the creation of foundations.
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ DEFINE_INTERFACE_METHOD("GetLosRevealWholeMap", ICmpRangeManager, GetLosRevealWh
|
|||
DEFINE_INTERFACE_METHOD("SetLosRevealWholeMapForAll", ICmpRangeManager, SetLosRevealWholeMapForAll)
|
||||
DEFINE_INTERFACE_METHOD("GetLosRevealWholeMapForAll", ICmpRangeManager, GetLosRevealWholeMapForAll)
|
||||
DEFINE_INTERFACE_METHOD("GetEffectiveParabolicRange", ICmpRangeManager, GetEffectiveParabolicRange)
|
||||
DEFINE_INTERFACE_METHOD("GetMaxReachableParabolicHeight", ICmpRangeManager, GetMaxReachableParabolicHeight)
|
||||
DEFINE_INTERFACE_METHOD("GetElevationAdaptedRange", ICmpRangeManager, GetElevationAdaptedRange)
|
||||
DEFINE_INTERFACE_METHOD("ActivateScriptedVisibility", ICmpRangeManager, ActivateScriptedVisibility)
|
||||
DEFINE_INTERFACE_METHOD("GetLosVisibility", ICmpRangeManager, GetLosVisibility_wrapper)
|
||||
|
|
|
|||
|
|
@ -195,6 +195,18 @@ public:
|
|||
*/
|
||||
virtual entity_pos_t GetEffectiveParabolicRange(entity_id_t source, entity_id_t target, entity_pos_t range, entity_pos_t yOrigin) const = 0;
|
||||
|
||||
/**
|
||||
* Get the max height (relative to the source) a parabolic projectile can reach
|
||||
* at a given horizontal distance.
|
||||
* @param source the entity at the origin.
|
||||
* @param range the maximum parabolic range on flat terrain.
|
||||
* @param yOrigin height bonus for the source.
|
||||
* @param horizDistance the horizontal distance to check.
|
||||
* @return the maximum reachable height difference (target height - source height),
|
||||
* or a very negative value if the horizontal distance exceeds the range.
|
||||
*/
|
||||
virtual entity_pos_t GetMaxReachableParabolicHeight(entity_pos_t range, entity_pos_t yOrigin, entity_pos_t horizDistance) const = 0;
|
||||
|
||||
/**
|
||||
* Get the average elevation over 8 points on distance range around the entity
|
||||
* @param id the entity id to look around
|
||||
|
|
|
|||
Loading…
Reference in a new issue