Compare commits

...

12 commits

Author SHA1 Message Date
Atrik
1b07b799ec Add test for queries with visibility checks
Some checks failed
checkrefs / lfscheck (push) Has been cancelled
checkrefs / checkrefs (push) Has been cancelled
lint / cppcheck (push) Has been cancelled
lint / copyright (push) Has been cancelled
lint / jenkinsfiles (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
2026-08-12 22:40:59 +02:00
Atrik
38b33b0484 Enable attacks on foes visible through shared LOS
Adds baseRange parameter to parabolic queries for combined
2D + parabolic detection. StandGround and Chase stances now
use attack range (parabolic) with vision as baseRange, allowing
units to attack enemies visible through friendly vision.
Buildings benefit as well via BuildingAI.
2026-08-12 22:40:59 +02:00
Atrik
ceca608848 Filter out hidden targets in CanAttack
Units should not be able to attack entities with "hidden" visibility.
Visible and fogged (mirage/retainInFog) targets remain attackable.
2026-08-12 22:40:59 +02:00
Atrik
f84b51212a Add tests for CanEverReachTarget and its helpers 2026-08-12 22:40:59 +02:00
Atrik
fc3c0d7876 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.
2026-08-12 22:40:59 +02:00
Atrik
db23584fc3 Add GetEffectiveAttackRange wrapper
Wraps RangeManager.GetEffectiveParabolicRange for unified range
resolution. Maintains symmetry with GetRange(), improves readability,
and consistently translates NEVER_IN_RANGE.

Simplifies callers in UnitAI and Attack.
2026-08-12 22:40:59 +02:00
Atrik
f12975e9b9 Fix range target detection for StandGround stance
StandGround units now use parabolic range queries to detect enemies,
accounting for terrain elevation and height offsets. This ensures
units on hills or when 'turreted', can detect enemies
that are in parabolic range but outside flat range.

Previously, StandGround detection used flat circular queries,
causing units to miss enemies in their elevation-buffed range.
Other stances are unaffected since they chase targets anyway.
2026-08-12 22:40:59 +02:00
Atrik
2bc895bf0d Rename cmp to rangeManager for clarity 2026-08-12 22:40:59 +02:00
Atrik
3c215aff47 Add some tests for GetEffectiveParabolicRange 2026-08-12 22:40:59 +02:00
Atrik
121d428ebb Fix terrain elevation not affecting attack range
The parabolic range formula in GetEffectiveParabolicRange was only
computating height differences from manual HeightOffset values,
completely ignoring actual terrain elevation.
This meant units on hills received no tactical advantage
despite the UI stat tooltip correctly showing extended ranges.

Fixes #8889
2026-08-12 22:40:59 +02:00
Atrik
a8426f06a9 Fix renamed entities on TurretPoint
Oversights from fb1c0d2a82

Fixes #8808
2026-08-12 22:40:59 +02:00
Atrik
57a5740cce Remove dead territory ownership code 2026-08-12 22:40:59 +02:00
13 changed files with 869 additions and 187 deletions

View file

@ -286,17 +286,21 @@ Attack.prototype.CanAttack = function(target, wantedTypes)
if (!cmpTargetPlayer || !cmpEntityPlayer)
return false;
// Must be visible or miraged / with retainInFog flag, not completely hidden
const cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager);
if (cmpRangeManager)
{
const visibility = cmpRangeManager.GetLosVisibility(target, cmpEntityPlayer.GetPlayerID());
if (visibility == "hidden")
return false;
}
const types = this.GetAttackTypes(wantedTypes);
const entityOwner = cmpEntityPlayer.GetPlayerID();
const targetOwner = cmpTargetPlayer.GetPlayerID();
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()))
@ -305,7 +309,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);
@ -319,6 +324,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.
*/
@ -353,12 +429,14 @@ Attack.prototype.GetPreference = function(target)
*/
Attack.prototype.GetFullAttackRange = function()
{
const ret = { "min": Infinity, "max": 0 };
const ret = { "min": Infinity, "max": 0, "parabolic": false };
for (const type of this.GetAttackTypes())
{
const range = this.GetRange(type);
ret.min = Math.min(ret.min, range.min);
ret.max = Math.max(ret.max, range.max);
if (range.parabolic)
ret.parabolic = true;
}
return ret;
};
@ -475,7 +553,39 @@ Attack.prototype.GetRange = function(type)
let min = +(this.template[type].MinRange || 0);
min = ApplyValueModificationsToEntity("Attack/" + type + "/MinRange", min, this.entity);
return { "max": max, "min": min };
return {
"max": max,
"min": min,
"parabolic": type === "Ranged"
};
};
/**
* Get the effective range for attacking a specific target, accounting
* for elevation and projectile physics where applicable.
* @param {number} target - The target entity ID.
* @param {string} type - The attack type.
* @return {{ min: number, max: number }} - The min and max effective range.
*/
Attack.prototype.GetEffectiveAttackRange = function(target, type)
{
const range = this.GetRange(type);
// Only Parabolic attacks get parabolic elevation adjustment
if (!range.parabolic)
return range;
const cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager);
if (!cmpRangeManager)
return range;
const effectiveMax = cmpRangeManager.GetEffectiveParabolicRange(
this.entity, target, range.max, this.GetAttackYOrigin(type));
if (effectiveMax < 0)
return { "min": Infinity, "max": 0 }; // Out of range
return { "min": range.min, "max": effectiveMax };
};
Attack.prototype.GetAttackYOrigin = function(type)
@ -813,14 +923,9 @@ Attack.prototype.PerformAttack = function(type, target)
*/
Attack.prototype.IsTargetInRange = function(target, type)
{
const range = this.GetRange(type);
return Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager).IsInTargetParabolicRange(
this.entity,
target,
range.min,
range.max,
this.GetAttackYOrigin(type),
false);
const range = this.GetEffectiveAttackRange(target, type);
return Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager).IsInTargetRange(
this.entity, target, range.min, range.max, false);
};
Attack.prototype.OnValueModification = function(msg)

View file

@ -127,10 +127,20 @@ 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,
enemies, IID_Resistance, cmpRangeManager.GetEntityFlagMask("normal"));
this.entity, range.min, range.max, baseRange, yOrigin,
enemies, IID_Resistance, cmpRangeManager.GetEntityFlagMask("normal"),
true // Allow mirages for attack queries
);
cmpRangeManager.EnableActiveQuery(this.enemyUnitsQuery);
};
@ -156,10 +166,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 +187,6 @@ BuildingAI.prototype.SetupGaiaRangeQuery = function()
*/
BuildingAI.prototype.OnRangeUpdate = function(msg)
{
var cmpAttack = Engine.QueryInterface(this.entity, IID_Attack);
if (!cmpAttack)
return;
@ -189,10 +205,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 +391,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);

View file

@ -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.
*/

View file

@ -4244,10 +4244,31 @@ UnitAI.prototype.SetupAttackRangeQuery = function(enable = true)
return;
const range = this.GetQueryRange(IID_Attack);
// Do not compensate for entity sizes: LOS doesn't, and UnitAI relies on that.
this.losAttackRangeQuery = cmpRangeManager.CreateActiveQuery(this.entity,
range.min, range.max, players, IID_Resistance,
cmpRangeManager.GetEntityFlagMask("normal"), false);
if (range.parabolic)
{
const cmpAttack = Engine.QueryInterface(this.entity, IID_Attack);
const yOrigin = cmpAttack ? cmpAttack.GetAttackYOrigin("Ranged") : 0;
// Do not compensate for entity sizes: LOS doesn't, and UnitAI relies on that.
this.losAttackRangeQuery = cmpRangeManager.CreateActiveParabolicQuery(
this.entity,
range.min,
range.max,
range.base,
yOrigin,
players,
IID_Resistance,
cmpRangeManager.GetEntityFlagMask("normal"),
true // Allow mirages for attack queries
);
}
else
this.losAttackRangeQuery = cmpRangeManager.CreateActiveQuery(this.entity,
range.min, range.max, players, IID_Resistance,
cmpRangeManager.GetEntityFlagMask("normal"),
false,
true // Allow mirages for attack queries
);
if (enable)
cmpRangeManager.EnableActiveQuery(this.losAttackRangeQuery);
@ -5157,24 +5178,24 @@ UnitAI.prototype.MoveToTargetAttackRange = function(target, type)
if (cmpFormation)
target = cmpFormation.GetClosestMemberToEntity(this.entity);
if (type != "Ranged")
return this.MoveToTargetRange(target, IID_Attack, type);
if (!this.CheckTargetVisible(target))
return false;
const cmpAttack = Engine.QueryInterface(this.entity, IID_Attack);
if (!cmpAttack)
return false;
const range = cmpAttack.GetRange(type);
// In case the range returns negative, we are probably too high compared to the target. Hope we come close enough.
const parabolicMaxRange = Math.max(0, Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager).GetEffectiveParabolicRange(this.entity, target, range.max, cmpAttack.GetAttackYOrigin(type)));
const flatRange = cmpAttack.GetRange(type);
const effectiveRange = cmpAttack.GetEffectiveAttackRange(target, type);
if (effectiveRange.max < 0)
return false;
// The parabole changes while walking so be cautious:
const guessedMaxRange = parabolicMaxRange > range.max ? (range.max + parabolicMaxRange) / 2 : parabolicMaxRange;
// The parabola changes while walking so be cautious:
const guessedMaxRange = effectiveRange.max > flatRange.max ?
(flatRange.max + effectiveRange.max) / 2 :
effectiveRange.max;
return cmpUnitMotion && cmpUnitMotion.MoveToTargetRange(target, range.min, guessedMaxRange);
return cmpUnitMotion && cmpUnitMotion.MoveToTargetRange(target, effectiveRange.min, guessedMaxRange);
};
UnitAI.prototype.MoveToTargetRangeExplicit = function(target, min, max)
@ -5584,8 +5605,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)
@ -6620,9 +6649,22 @@ UnitAI.prototype.FindWalkAndFightTargets = function()
return false;
};
/**
* Returns the detection range for the given interface, adjusted by stance.
*
* The query range depends on stance because it represents the distance at which
* 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, base: 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.
* 'base' is a non-parabolic 2D detection range that always counts as in-range.
*/
UnitAI.prototype.GetQueryRange = function(iid)
{
const ret = { "min": 0, "max": 0 };
const ret = { "min": 0, "max": 0, "base": 0, "parabolic": false };
const cmpVision = Engine.QueryInterface(this.entity, IID_Vision);
if (!cmpVision)
@ -6635,27 +6677,35 @@ UnitAI.prototype.GetQueryRange = function(iid)
return ret;
}
if (this.GetStance().respondStandGround)
{
const range = this.GetRange(iid);
if (!range)
return ret;
ret.min = range.min;
ret.max = Math.min(range.max, visionRange);
}
else if (this.GetStance().respondChase)
ret.max = visionRange;
const range = this.GetRange(iid);
if (!range)
return ret;
// The query range depends on stance because it represents the distance at which
// the unit should "notice" an enemy and potentially start moving toward it.
// In all stances, always spot targets within effective attack/heal range.
Object.assign(ret, range);
let nonParabolicMax = 0;
if (this.GetStance().respondChase)
// Chase: Always spot targets within vision range, so we can chase them.
nonParabolicMax = visionRange;
else if (this.GetStance().respondHoldGround)
{
const range = this.GetRange(iid);
if (!range)
return ret;
ret.max = Math.min(range.max + visionRange / 2, visionRange);
}
// HoldGround: willing to move a bit, so spot targets within attack range + half vision.
nonParabolicMax = Math.min(range.max + visionRange / 2, visionRange);
// StandGround: nonParabolicMax stays 0, using only parabolic range.
// We probably have stance 'passive' and we wouldn't have a range,
// but as it is the default for healers we need to set it to something sane.
else if (iid === IID_Heal)
ret.max = visionRange;
nonParabolicMax = visionRange;
if (ret.parabolic)
ret.base = nonParabolicMax;
else
ret.max = nonParabolicMax;
return ret;
};

View file

@ -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, {
@ -201,7 +213,7 @@ attackComponentTest(undefined, true, (attacker, cmpAttack, defender) =>
TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetPreferredClasses("Melee"), ["Civilian"]);
TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetRestrictedClasses("Melee"), ["Elephant", "Archer"]);
TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetFullAttackRange(), { "min": 0, "max": 80 });
TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetFullAttackRange(), { "min": 0, "max": 80, "parabolic": true });
TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackEffectsData("Capture"), { "Capture": 8 });
TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackEffectsData("Ranged"), {
@ -416,3 +428,101 @@ function testAttackPreference()
TS_ASSERT_EQUALS(cmpAttack.GetPreference(attacker+4), undefined);
}
testAttackPreference();
function testCanEverReachTarget()
{
const attacker = ++entityID;
AddMock(attacker, IID_Position, {
"IsInWorld": () => true,
"GetHeightOffset": () => 0,
"GetPosition2D": () => new Vector2D(1, 2)
});
const cmpAttack = ConstructComponent(attacker, "Attack", {
"Melee": {
"Damage": { "Hack": 10, "Pierce": 0, "Crush": 0 },
"MaxRange": 5
},
"Ranged": {
"Damage": { "Hack": 0, "Pierce": 10, "Crush": 0 },
"MaxRange": 30,
"Projectile": { "Speed": 50, "Spread": 1, "Gravity": 1, "FriendlyFire": "false" }
}
});
// Melee target within 3D range
{
const defender = ++entityID;
AddMock(defender, IID_Position, {
"IsInWorld": () => true,
"GetHeightOffset": () => 0
});
TS_ASSERT_EQUALS(cmpAttack.CanEverReachTarget(defender, "Melee"), true);
}
// Melee target too high
{
const defender = ++entityID;
AddMock(defender, IID_Position, {
"IsInWorld": () => true,
"GetHeightOffset": () => 10
});
TS_ASSERT_EQUALS(cmpAttack.CanEverReachTarget(defender, "Melee"), false);
}
// Melee target at same height, within range (close distance)
{
const defender = ++entityID;
AddMock(defender, IID_Position, {
"IsInWorld": () => true,
"GetHeightOffset": () => 4
});
// sqrt(0² + 4²) = 4 <= 5
TS_ASSERT_EQUALS(cmpAttack.CanEverReachTarget(defender, "Melee"), true);
}
// Ranged: target at same height — reachable from current position (check 1)
{
const defender = ++entityID;
AddMock(defender, IID_Position, {
"IsInWorld": () => true,
"GetHeightOffset": () => 0,
"GetPosition": () => new Vector3D(1, 0, 2)
});
// Need RangeManager mock for IsTargetInRange (check 1) to work
AddMock(SYSTEM_ENTITY, IID_RangeManager, {
"GetEffectiveParabolicRange": () => 25,
"GetMaxReachableParabolicHeight": () => 15
});
AddMock(SYSTEM_ENTITY, IID_ObstructionManager, {
"IsInTargetRange": () => true
});
TS_ASSERT_EQUALS(cmpAttack.CanEverReachTarget(defender, "Ranged"), true);
}
// Ranged: target too high for parabolic arc even at closest approach (check 2)
{
const defender = ++entityID;
AddMock(defender, IID_Position, {
"IsInWorld": () => true,
"GetHeightOffset": () => 20,
"GetPosition": () => new Vector3D(1, 20, 2)
});
AddMock(SYSTEM_ENTITY, IID_RangeManager, {
"GetEffectiveParabolicRange": () => -1, // out of range
"GetMaxReachableParabolicHeight": () => 10
});
AddMock(SYSTEM_ENTITY, IID_ObstructionManager, {
"IsInTargetRange": () => false
});
// heightDiff = 20 - 0 = 20, maxReachableHeightDiff = 10 → unreachable
TS_ASSERT_EQUALS(cmpAttack.CanEverReachTarget(defender, "Ranged"), false);
}
}
testCanEverReachTarget();

View file

@ -15,6 +15,7 @@ const enemyPlayer = 2;
const alliedPlayer = 3;
const turretHolderID = 9;
const entitiesToTest = [10, 11, 12, 13];
let entityID = 100;
AddMock(turretHolderID, IID_Ownership, {
"GetOwner": () => player
@ -244,3 +245,80 @@ cmpTurretHolder.OnOwnershipChanged({
"from": INVALID_PLAYER
});
TS_ASSERT(cmpTurretHolder.OccupiesTurretPoint(spawned));
// Test GetClosestApproachDistanceToTurretPoint
{
const holder = ++entityID;
// Mock the holder's obstruction
AddMock(holder, IID_Obstruction, {
"GetBlockMovementFlag": () => true,
"GetObstructionHalfSizes": () => ({ "x": 10, "y": 15 })
});
const cmpHolder = ConstructComponent(holder, "TurretHolder", {
"TurretPoints": {
"center": {
"X": "0",
"Y": "5.0",
"Z": "0"
},
"edge": {
"X": "8.0",
"Y": "5.0",
"Z": "0"
},
"corner": {
"X": "10.0",
"Y": "5.0",
"Z": "15.0"
},
"outside": {
"X": "15.0",
"Y": "5.0",
"Z": "0"
}
}
});
// Center point (0,0) in 20x30 building → min(10, 15) = 10
TS_ASSERT_EQUALS(cmpHolder.GetClosestApproachDistanceToTurretPoint("center"), 10);
// Edge point (8,0) in 20x30 building → min(10-8, 15-0) = 2
TS_ASSERT_EQUALS(cmpHolder.GetClosestApproachDistanceToTurretPoint("edge"), 2);
// Corner point (10,15) in 20x30 building → min(10-10, 15-15) = 0
TS_ASSERT_EQUALS(cmpHolder.GetClosestApproachDistanceToTurretPoint("corner"), 0);
// Outside point (15,0) in 20x30 building → min(10-15, 15-0) = -5 → clamped to 0
TS_ASSERT_EQUALS(cmpHolder.GetClosestApproachDistanceToTurretPoint("outside"), 0);
// Nonexistent turret point
TS_ASSERT_EQUALS(cmpHolder.GetClosestApproachDistanceToTurretPoint("nonexistent"), 0);
// Pass object directly
const turretPoint = cmpHolder.TurretPointByName("center");
TS_ASSERT_EQUALS(cmpHolder.GetClosestApproachDistanceToTurretPoint(turretPoint), 10);
// Passable building (no obstruction or doesn't block movement)
const passableHolder = ++entityID;
AddMock(passableHolder, IID_Obstruction, {
"GetBlockMovementFlag": () => false
});
const cmpHolderPassable = ConstructComponent(passableHolder, "TurretHolder", {
"TurretPoints": {
"center": { "X": "0", "Y": "5.0", "Z": "0" }
}
});
TS_ASSERT_EQUALS(cmpHolderPassable.GetClosestApproachDistanceToTurretPoint("center"), 0);
// No obstruction component at all
++entityID;
const cmpHolderNoObst = ConstructComponent(entityID, "TurretHolder", {
"TurretPoints": {
"center": { "X": "0", "Y": "5.0", "Z": "0" }
}
});
TS_ASSERT_EQUALS(cmpHolderNoObst.GetClosestApproachDistanceToTurretPoint("center"), 0);
}

View file

@ -24,6 +24,12 @@ function ChangeEntityTemplate(oldEnt, newTemplate)
if (cmpVisual && cmpNewVisual)
cmpNewVisual.SetActorSeed(cmpVisual.GetActorSeed());
// Set ownership so turret checks work properly
const cmpOwnership = Engine.QueryInterface(oldEnt, IID_Ownership);
const cmpNewOwnership = Engine.QueryInterface(newEnt, IID_Ownership);
if (cmpOwnership && cmpNewOwnership)
cmpNewOwnership.SetOwner(cmpOwnership.GetOwner());
const cmpOldTurretable = Engine.QueryInterface(oldEnt, IID_Turretable);
// If the old entity is turreted, we need to handle it before copying position
@ -39,9 +45,15 @@ function ChangeEntityTemplate(oldEnt, newTemplate)
// Check if it's allowed to occupy the turret point
const cmpTurretHolderOfOldEnt = Engine.QueryInterface(cmpOldTurretable.HolderID(), IID_TurretHolder);
if (cmpTurretHolderOfNewEnt &&
!cmpTurretHolderOfOldEnt.AllowedToOccupyTurretPoint(newEnt, cmpOldTurretable.GetTurretPointName(), true))
cmpOldTurretable.LeaveTurret(true);
if (cmpTurretHolderOfOldEnt)
{
// Find the actual turret point object using the old entity
const turretPoint = cmpTurretHolderOfOldEnt.GetOccupiedTurretPoint(oldEnt);
if (!turretPoint || !cmpTurretHolderOfOldEnt.AllowedToOccupyTurretPoint(newEnt, turretPoint, true))
cmpOldTurretable.LeaveTurret(true);
// If allowed, don't leave the turret - OnEntityRenamed will handle the swap
}
}
}
@ -86,24 +98,6 @@ function ChangeEntityTemplate(oldEnt, newTemplate)
for (const entity of cmpTurretHolder.GetEntities())
cmpNewTurretHolder.SetReservedTurretPoint(cmpTurretHolder.GetOccupiedTurretPointName(entity));
let owner;
const cmpTerritoryDecay = Engine.QueryInterface(newEnt, IID_TerritoryDecay);
if (cmpTerritoryDecay && cmpTerritoryDecay.HasTerritoryOwnership() && cmpNewPosition)
{
const pos = cmpNewPosition.GetPosition2D();
const cmpTerritoryManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_TerritoryManager);
owner = cmpTerritoryManager.GetOwner(pos.x, pos.y);
}
else
{
const cmpOwnership = Engine.QueryInterface(oldEnt, IID_Ownership);
if (cmpOwnership)
owner = cmpOwnership.GetOwner();
}
const cmpNewOwnership = Engine.QueryInterface(newEnt, IID_Ownership);
if (cmpNewOwnership)
cmpNewOwnership.SetOwner(owner);
CopyControlGroups(oldEnt, newEnt);
// Rescale capture points

View file

@ -188,6 +188,7 @@ struct Query
CEntityHandle source; // TODO: this could crash if an entity is destroyed while a Query is still referencing it
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;
@ -195,6 +196,7 @@ struct Query
bool enabled;
bool parabolic;
bool accountForSize; // If true, the query accounts for unit sizes, otherwise it treats all entities as points.
bool preferMirages; // If true, include mirages and filter HIDDEN entities. Otherwise exclude mirages and no visibility filter.
};
/**
@ -349,6 +351,7 @@ struct SerializeHelper<Query>
{
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);
@ -357,6 +360,7 @@ struct SerializeHelper<Query>
serialize.Bool("enabled", value.enabled);
serialize.Bool("parabolic",value.parabolic);
serialize.Bool("account for size",value.accountForSize);
serialize.Bool("preferMirages", value.preferMirages);
}
void operator()(ISerializer& serialize, const char* name, Query& value, const CSimContext&)
@ -992,21 +996,20 @@ public:
tag_t CreateActiveQuery(entity_id_t source,
entity_pos_t minRange, entity_pos_t maxRange,
const std::vector<int>& owners, int requiredInterface, u8 flags, bool accountForSize) override
const std::vector<int>& owners, int requiredInterface, u8 flags,
bool accountForSize, bool preferMirages) override
{
tag_t id = m_QueryNext++;
m_Queries[id] = ConstructQuery(source, minRange, maxRange, owners, requiredInterface, flags, accountForSize);
m_Queries[id] = ConstructQuery(source, minRange, maxRange, owners, requiredInterface, flags, accountForSize, preferMirages);
return id;
}
tag_t CreateActiveParabolicQuery(entity_id_t source,
entity_pos_t minRange, entity_pos_t maxRange, entity_pos_t yOrigin,
const std::vector<int>& owners, int requiredInterface, u8 flags) override
entity_pos_t minRange, entity_pos_t maxRange, entity_pos_t baseRange, entity_pos_t yOrigin,
const std::vector<int>& owners, int requiredInterface, u8 flags, bool preferMirages = false) 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, preferMirages);
return id;
}
@ -1297,10 +1300,35 @@ public:
if (id == q.source.GetId())
return false;
// Ignore if it's missing the required interface
if (q.interface && !GetSimContext().GetComponentManager().QueryInterface(id, q.interface))
// Check if this is a mirage entity
CmpPtr<ICmpMirage> cmpMirage(GetSimContext(), id);
bool isMirage = !!cmpMirage;
// If it's a mirage and we're not including mirages, skip it
if (isMirage && !q.preferMirages)
return false;
// If it's not a mirage, check interface normally
if (!isMirage && q.interface && !GetSimContext().GetComponentManager().QueryInterface(id, q.interface))
return false;
// Filter hidden entities when we want mirages (i.e., we care about visibility)
if (q.preferMirages && q.source.GetId() != INVALID_ENTITY)
{
// Look up the source's current owner
EntityMap<EntityData>::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;
}
@ -1324,13 +1352,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<ICmpPosition> 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)
{
@ -1340,6 +1373,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<ICmpPosition> cmpSecondPosition(GetSimContext(), subdivisionResultsBuffer[i]);
if (!cmpSecondPosition || !cmpSecondPosition->IsInWorld())
continue;
@ -1357,7 +1404,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);
@ -1394,6 +1441,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.
@ -1408,13 +1471,32 @@ public:
if (!cmpTargetPosition || !cmpTargetPosition->IsInWorld())
return NEVER_IN_RANGE;
entity_pos_t heightDifference = cmpSourcePosition->GetHeightOffset() - cmpTargetPosition->GetHeightOffset() + yOrigin;
if (heightDifference < -range / 2)
return NEVER_IN_RANGE;
// GetPosition() returns the world height (terrain + water + offset)
CFixedVector3D sourcePos = cmpSourcePosition->GetPosition();
CFixedVector3D targetPos = cmpTargetPosition->GetPosition();
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 heightDiff = sourcePos.Y - targetPos.Y + yOrigin;
return ComputeParabolicRange(range, heightDiff);
}
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 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
@ -1519,7 +1601,8 @@ public:
Query ConstructQuery(entity_id_t source,
entity_pos_t minRange, entity_pos_t maxRange,
const std::vector<int>& owners, int requiredInterface, u8 flagsMask, bool accountForSize) const
const std::vector<int>& owners, int requiredInterface, u8 flagsMask,
bool accountForSize, bool preferMirages = false) const
{
// Min range must be non-negative.
if (minRange < entity_pos_t::Zero())
@ -1530,6 +1613,8 @@ 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<ICmpOwnership> cmpOwnership(GetSimContext(), source);
Query q;
q.enabled = false;
q.parabolic = false;
@ -1538,6 +1623,7 @@ public:
q.maxRange = maxRange;
q.yOrigin = entity_pos_t::Zero();
q.accountForSize = accountForSize;
q.preferMirages = preferMirages;
if (q.accountForSize && q.source.GetId() != INVALID_ENTITY && q.maxRange != ALWAYS_IN_RANGE)
{
@ -1574,12 +1660,14 @@ public:
}
Query ConstructParabolicQuery(entity_id_t source,
entity_pos_t minRange, entity_pos_t maxRange, entity_pos_t yOrigin,
const std::vector<int>& owners, int requiredInterface, u8 flagsMask, bool accountForSize) const
entity_pos_t minRange, entity_pos_t maxRange, entity_pos_t baseRange, entity_pos_t yOrigin,
const std::vector<int>& owners, int requiredInterface, u8 flagsMask,
bool accountForSize, bool preferMirages = false) const
{
Query q = ConstructQuery(source, minRange, maxRange, owners, requiredInterface, flagsMask, accountForSize);
Query q = ConstructQuery(source, minRange, maxRange, owners, requiredInterface, flagsMask, accountForSize, preferMirages);
q.parabolic = true;
q.yOrigin = yOrigin;
q.baseRange = baseRange;
return q;
}

View file

@ -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)

View file

@ -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.

View file

@ -68,6 +68,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)

View file

@ -161,29 +161,42 @@ public:
* @param requiredInterface if non-zero, an interface ID that matching entities must implement.
* @param flags if a entity in range has one of the flags set it will show up.
* @param accountForSize if true, compensate for source/target entity sizes.
* @param preferMirages if true, mirage entities are included (bypassing interface checks)
* and HIDDEN entities are filtered out (needed for targeting fogged enemies).
* When false (default), mirages are excluded entirely and real entities
* are returned regardless of visibility (even if hidden).
* @return unique non-zero identifier of query.
*/
virtual tag_t CreateActiveQuery(entity_id_t source, entity_pos_t minRange, entity_pos_t maxRange,
const std::vector<int>& owners, int requiredInterface, u8 flags, bool accountForSize) = 0;
virtual tag_t CreateActiveQuery(entity_id_t source,
entity_pos_t minRange, entity_pos_t maxRange,
const std::vector<int>& owners, int requiredInterface, u8 flags,
bool accountForSize, bool preferMirages = false) = 0;
/**
* Construct an active query of a paraboloic form around the unit.
/**
* Construct an active query of a parabolic form around the unit.
* The query will be disabled by default.
* @param source the entity around which the range will be computed.
* @param minRange non-negative minimum horizontal distance in metres (inclusive). MinRange doesn't do parabolic checks.
* @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.
* @param flags if a entity in range has one of the flags set it will show up.
* @param preferMirages if true, mirage entities are included (bypassing interface checks)
* and HIDDEN entities are filtered out (needed for targeting fogged enemies).
* When false (default), mirages are excluded entirely and real entities
* are returned regardless of visibility (even if hidden).
* 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,
const std::vector<int>& owners, int requiredInterface, u8 flags) = 0;
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<int>& owners, int requiredInterface, u8 flags, bool preferMirages = false) = 0;
/**
* Get the effective range in a parablic range query.
@ -195,6 +208,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

View file

@ -63,13 +63,13 @@ public:
entity_id_t GetTurretParent() const override {return INVALID_ENTITY;}
void UpdateTurretPosition() override {}
std::set<entity_id_t>* GetTurrets() override { return nullptr; }
bool IsInWorld() const override { return true; }
void MoveOutOfWorld() override { }
bool IsInWorld() const override { return m_InWorld; }
void MoveOutOfWorld() override { m_InWorld = false; }
void MoveTo(entity_pos_t /*x*/, entity_pos_t /*z*/) override { }
void MoveAndTurnTo(entity_pos_t /*x*/, entity_pos_t /*z*/, entity_angle_t /*a*/) override { }
void JumpTo(entity_pos_t /*x*/, entity_pos_t /*z*/) override { }
void SetHeightOffset(entity_pos_t /*dy*/) override { }
entity_pos_t GetHeightOffset() const override { return entity_pos_t::Zero(); }
void SetHeightOffset(entity_pos_t dy) override { m_HeightOffset = dy; }
entity_pos_t GetHeightOffset() const override { return m_HeightOffset; }
void SetHeightFixed(entity_pos_t /*y*/) override { }
entity_pos_t GetHeightFixed() const override { return entity_pos_t::Zero(); }
entity_pos_t GetHeightAtFixed(entity_pos_t, entity_pos_t) const override { return entity_pos_t::Zero(); }
@ -94,6 +94,8 @@ public:
CMatrix3D GetInterpolatedTransform(float /*frameOffset*/) const override { return CMatrix3D(); }
CFixedVector3D m_Pos;
entity_pos_t m_HeightOffset = entity_pos_t::Zero();
bool m_InWorld = true;
};
class MockObstructionRgm : public ICmpObstruction
@ -154,7 +156,7 @@ public:
{
ComponentTestHelper test(*g_ScriptContext);
ICmpRangeManager* cmp = test.Add<ICmpRangeManager>(CID_RangeManager, "", SYSTEM_ENTITY);
ICmpRangeManager* rangeManager = test.Add<ICmpRangeManager>(CID_RangeManager, "", SYSTEM_ENTITY);
MockVisionRgm vision;
test.AddMock(100, IID_Vision, vision);
@ -165,41 +167,41 @@ public:
// This tests that the incremental computation produces the correct result
// in various edge cases
cmp->SetBounds(entity_pos_t::FromInt(0), entity_pos_t::FromInt(0), entity_pos_t::FromInt(512), entity_pos_t::FromInt(512));
cmp->Verify();
{ CMessageCreate msg(100); cmp->HandleMessage(msg, false); }
cmp->Verify();
{ CMessageOwnershipChanged msg(100, -1, 1); cmp->HandleMessage(msg, false); }
cmp->Verify();
{ CMessagePositionChanged msg(100, true, entity_pos_t::FromInt(247), entity_pos_t::FromDouble(257.95), entity_angle_t::Zero()); cmp->HandleMessage(msg, false); }
cmp->Verify();
{ CMessagePositionChanged msg(100, true, entity_pos_t::FromInt(247), entity_pos_t::FromInt(253), entity_angle_t::Zero()); cmp->HandleMessage(msg, false); }
cmp->Verify();
rangeManager->SetBounds(entity_pos_t::FromInt(0), entity_pos_t::FromInt(0), entity_pos_t::FromInt(512), entity_pos_t::FromInt(512));
rangeManager->Verify();
{ CMessageCreate msg(100); rangeManager->HandleMessage(msg, false); }
rangeManager->Verify();
{ CMessageOwnershipChanged msg(100, -1, 1); rangeManager->HandleMessage(msg, false); }
rangeManager->Verify();
{ CMessagePositionChanged msg(100, true, entity_pos_t::FromInt(247), entity_pos_t::FromDouble(257.95), entity_angle_t::Zero()); rangeManager->HandleMessage(msg, false); }
rangeManager->Verify();
{ CMessagePositionChanged msg(100, true, entity_pos_t::FromInt(247), entity_pos_t::FromInt(253), entity_angle_t::Zero()); rangeManager->HandleMessage(msg, false); }
rangeManager->Verify();
{ CMessagePositionChanged msg(100, true, entity_pos_t::FromInt(256), entity_pos_t::FromInt(256), entity_angle_t::Zero()); cmp->HandleMessage(msg, false); }
cmp->Verify();
{ CMessagePositionChanged msg(100, true, entity_pos_t::FromInt(256), entity_pos_t::FromInt(256), entity_angle_t::Zero()); rangeManager->HandleMessage(msg, false); }
rangeManager->Verify();
{ CMessagePositionChanged msg(100, true, entity_pos_t::FromInt(256)+entity_pos_t::Epsilon(), entity_pos_t::FromInt(256), entity_angle_t::Zero()); cmp->HandleMessage(msg, false); }
cmp->Verify();
{ CMessagePositionChanged msg(100, true, entity_pos_t::FromInt(256)-entity_pos_t::Epsilon(), entity_pos_t::FromInt(256), entity_angle_t::Zero()); cmp->HandleMessage(msg, false); }
cmp->Verify();
{ CMessagePositionChanged msg(100, true, entity_pos_t::FromInt(256), entity_pos_t::FromInt(256)+entity_pos_t::Epsilon(), entity_angle_t::Zero()); cmp->HandleMessage(msg, false); }
cmp->Verify();
{ CMessagePositionChanged msg(100, true, entity_pos_t::FromInt(256), entity_pos_t::FromInt(256)-entity_pos_t::Epsilon(), entity_angle_t::Zero()); cmp->HandleMessage(msg, false); }
cmp->Verify();
{ CMessagePositionChanged msg(100, true, entity_pos_t::FromInt(256)+entity_pos_t::Epsilon(), entity_pos_t::FromInt(256), entity_angle_t::Zero()); rangeManager->HandleMessage(msg, false); }
rangeManager->Verify();
{ CMessagePositionChanged msg(100, true, entity_pos_t::FromInt(256)-entity_pos_t::Epsilon(), entity_pos_t::FromInt(256), entity_angle_t::Zero()); rangeManager->HandleMessage(msg, false); }
rangeManager->Verify();
{ CMessagePositionChanged msg(100, true, entity_pos_t::FromInt(256), entity_pos_t::FromInt(256)+entity_pos_t::Epsilon(), entity_angle_t::Zero()); rangeManager->HandleMessage(msg, false); }
rangeManager->Verify();
{ CMessagePositionChanged msg(100, true, entity_pos_t::FromInt(256), entity_pos_t::FromInt(256)-entity_pos_t::Epsilon(), entity_angle_t::Zero()); rangeManager->HandleMessage(msg, false); }
rangeManager->Verify();
{ CMessagePositionChanged msg(100, true, entity_pos_t::FromInt(383), entity_pos_t::FromInt(84), entity_angle_t::Zero()); cmp->HandleMessage(msg, false); }
cmp->Verify();
{ CMessagePositionChanged msg(100, true, entity_pos_t::FromInt(348), entity_pos_t::FromInt(83), entity_angle_t::Zero()); cmp->HandleMessage(msg, false); }
cmp->Verify();
{ CMessagePositionChanged msg(100, true, entity_pos_t::FromInt(383), entity_pos_t::FromInt(84), entity_angle_t::Zero()); rangeManager->HandleMessage(msg, false); }
rangeManager->Verify();
{ CMessagePositionChanged msg(100, true, entity_pos_t::FromInt(348), entity_pos_t::FromInt(83), entity_angle_t::Zero()); rangeManager->HandleMessage(msg, false); }
rangeManager->Verify();
std::mt19937 rng;
for (size_t i = 0; i < 1024; ++i)
{
double x = std::uniform_real_distribution<double>(0.0, 512.0)(rng);
double z = std::uniform_real_distribution<double>(0.0, 512.0)(rng);
{ CMessagePositionChanged msg(100, true, entity_pos_t::FromDouble(x), entity_pos_t::FromDouble(z), entity_angle_t::Zero()); cmp->HandleMessage(msg, false); }
cmp->Verify();
{ CMessagePositionChanged msg(100, true, entity_pos_t::FromDouble(x), entity_pos_t::FromDouble(z), entity_angle_t::Zero()); rangeManager->HandleMessage(msg, false); }
rangeManager->Verify();
}
// Test OwnershipChange, GetEntitiesByPlayer, GetNonGaiaEntities
@ -208,22 +210,22 @@ public:
for (player_id_t newOwner = 0; newOwner < 8; ++newOwner)
{
CMessageOwnershipChanged msg(100, previousOwner, newOwner);
cmp->HandleMessage(msg, false);
rangeManager->HandleMessage(msg, false);
for (player_id_t i = 0; i < 8; ++i)
TS_ASSERT_EQUALS(cmp->GetEntitiesByPlayer(i).size(), i == newOwner ? 1 : 0);
TS_ASSERT_EQUALS(rangeManager->GetEntitiesByPlayer(i).size(), i == newOwner ? 1 : 0);
TS_ASSERT_EQUALS(cmp->GetNonGaiaEntities().size(), newOwner > 0 ? 1 : 0);
TS_ASSERT_EQUALS(rangeManager->GetNonGaiaEntities().size(), newOwner > 0 ? 1 : 0);
previousOwner = newOwner;
}
}
}
void test_queries()
void test_range_queries_distance_only()
{
ComponentTestHelper test(*g_ScriptContext);
ICmpRangeManager* cmp = test.Add<ICmpRangeManager>(CID_RangeManager, "", SYSTEM_ENTITY);
ICmpRangeManager* rangeManager = test.Add<ICmpRangeManager>(CID_RangeManager, "", SYSTEM_ENTITY);
MockVisionRgm vision, vision2;
MockPositionRgm position, position2;
@ -236,101 +238,204 @@ public:
test.AddMock(101, IID_Position, position2);
test.AddMock(101, IID_Obstruction, obs2);
cmp->SetBounds(entity_pos_t::FromInt(0), entity_pos_t::FromInt(0), entity_pos_t::FromInt(512), entity_pos_t::FromInt(512));
cmp->Verify();
{ CMessageCreate msg(100); cmp->HandleMessage(msg, false); }
{ CMessageCreate msg(101); cmp->HandleMessage(msg, false); }
rangeManager->SetBounds(entity_pos_t::FromInt(0), entity_pos_t::FromInt(0), entity_pos_t::FromInt(512), entity_pos_t::FromInt(512));
rangeManager->Verify();
{ CMessageCreate msg(100); rangeManager->HandleMessage(msg, false); }
{ CMessageCreate msg(101); rangeManager->HandleMessage(msg, false); }
{ CMessageOwnershipChanged msg(100, -1, 1); cmp->HandleMessage(msg, false); }
{ CMessageOwnershipChanged msg(101, -1, 1); cmp->HandleMessage(msg, false); }
// Don't set ownership for either entity - leave both as INVALID_PLAYER.
// This bypasses the visibility check in TestEntityQuery, allowing us to test
// the core distance calculation logic independently of the LOS system.
auto move = [&cmp](entity_id_t ent, MockPositionRgm& pos, fixed x, fixed z) {
auto move = [&rangeManager](entity_id_t ent, MockPositionRgm& pos, fixed x, fixed z) {
pos.m_Pos = CFixedVector3D(x, fixed::Zero(), z);
{ CMessagePositionChanged msg(ent, true, x, z, entity_angle_t::Zero()); cmp->HandleMessage(msg, false); }
{ CMessagePositionChanged msg(ent, true, x, z, entity_angle_t::Zero()); rangeManager->HandleMessage(msg, false); }
};
move(100, position, fixed::FromInt(10), fixed::FromInt(10));
move(101, position2, fixed::FromInt(10), fixed::FromInt(20));
std::vector<entity_id_t> nearby = cmp->ExecuteQuery(100, fixed::FromInt(0), fixed::FromInt(4), {1}, 0, true);
// Query for owner -1 (INVALID_PLAYER) since both entities have no owner
std::vector<entity_id_t> nearby = rangeManager->ExecuteQuery(100, fixed::FromInt(0), fixed::FromInt(4), {-1}, 0, true);
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{});
nearby = cmp->ExecuteQuery(100, fixed::FromInt(4), fixed::FromInt(50), {1}, 0, true);
nearby = rangeManager->ExecuteQuery(100, fixed::FromInt(4), fixed::FromInt(50), {-1}, 0, true);
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{101});
move(101, position2, fixed::FromInt(10), fixed::FromInt(10));
nearby = cmp->ExecuteQuery(100, fixed::FromInt(0), fixed::FromInt(4), {1}, 0, true);
nearby = rangeManager->ExecuteQuery(100, fixed::FromInt(0), fixed::FromInt(4), {-1}, 0, true);
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{101});
nearby = cmp->ExecuteQuery(100, fixed::FromInt(4), fixed::FromInt(50), {1}, 0, true);
nearby = rangeManager->ExecuteQuery(100, fixed::FromInt(4), fixed::FromInt(50), {-1}, 0, true);
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{});
move(101, position2, fixed::FromInt(10), fixed::FromInt(13));
nearby = cmp->ExecuteQuery(100, fixed::FromInt(0), fixed::FromInt(4), {1}, 0, true);
nearby = rangeManager->ExecuteQuery(100, fixed::FromInt(0), fixed::FromInt(4), {-1}, 0, true);
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{101});
nearby = cmp->ExecuteQuery(100, fixed::FromInt(4), fixed::FromInt(50), {1}, 0, true);
nearby = rangeManager->ExecuteQuery(100, fixed::FromInt(4), fixed::FromInt(50), {-1}, 0, true);
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{});
move(101, position2, fixed::FromInt(10), fixed::FromInt(15));
// In range thanks to self obstruction size.
nearby = cmp->ExecuteQuery(100, fixed::FromInt(0), fixed::FromInt(4), {1}, 0, true);
nearby = rangeManager->ExecuteQuery(100, fixed::FromInt(0), fixed::FromInt(4), {-1}, 0, true);
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{101});
// In range thanks to target obstruction size.
nearby = cmp->ExecuteQuery(101, fixed::FromInt(0), fixed::FromInt(4), {1}, 0, true);
nearby = rangeManager->ExecuteQuery(101, fixed::FromInt(0), fixed::FromInt(4), {-1}, 0, true);
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{100});
// Trickier: min-range is closest-to-closest, but rotation may change the real distance.
nearby = cmp->ExecuteQuery(100, fixed::FromInt(2), fixed::FromInt(50), {1}, 0, true);
nearby = rangeManager->ExecuteQuery(100, fixed::FromInt(2), fixed::FromInt(50), {-1}, 0, true);
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{101});
nearby = cmp->ExecuteQuery(100, fixed::FromInt(5), fixed::FromInt(50), {1}, 0, true);
nearby = rangeManager->ExecuteQuery(100, fixed::FromInt(5), fixed::FromInt(50), {-1}, 0, true);
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{101});
nearby = cmp->ExecuteQuery(100, fixed::FromInt(6), fixed::FromInt(50), {1}, 0, true);
nearby = rangeManager->ExecuteQuery(100, fixed::FromInt(6), fixed::FromInt(50), {-1}, 0, true);
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{});
nearby = cmp->ExecuteQuery(101, fixed::FromInt(5), fixed::FromInt(50), {1}, 0, true);
nearby = rangeManager->ExecuteQuery(101, fixed::FromInt(5), fixed::FromInt(50), {-1}, 0, true);
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{100});
nearby = cmp->ExecuteQuery(101, fixed::FromInt(6), fixed::FromInt(50), {1}, 0, true);
nearby = rangeManager->ExecuteQuery(101, fixed::FromInt(6), fixed::FromInt(50), {-1}, 0, true);
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{});
}
void test_IsInTargetParabolicRange()
void test_range_queries_visibility_filtering()
{
ComponentTestHelper test(*g_ScriptContext);
ICmpRangeManager* cmp = test.Add<ICmpRangeManager>(CID_RangeManager, "", SYSTEM_ENTITY);
ICmpRangeManager* rangeManager = test.Add<ICmpRangeManager>(CID_RangeManager, "", SYSTEM_ENTITY);
MockVisionRgm vision, vision2;
MockPositionRgm position, position2;
MockObstructionRgm obs(fixed::FromInt(2)), obs2(fixed::Zero());
test.AddMock(100, IID_Vision, vision);
test.AddMock(100, IID_Position, position);
test.AddMock(100, IID_Obstruction, obs);
test.AddMock(101, IID_Vision, vision2);
test.AddMock(101, IID_Position, position2);
test.AddMock(101, IID_Obstruction, obs2);
rangeManager->SetBounds(entity_pos_t::FromInt(0), entity_pos_t::FromInt(0), entity_pos_t::FromInt(512), entity_pos_t::FromInt(512));
rangeManager->Verify();
{ CMessageCreate msg(100); rangeManager->HandleMessage(msg, false); }
{ CMessageCreate msg(101); rangeManager->HandleMessage(msg, false); }
// Set ownership for both entities so they have proper owners
{ CMessageOwnershipChanged msg(100, -1, 1); rangeManager->HandleMessage(msg, false); }
{ CMessageOwnershipChanged msg(101, -1, 1); rangeManager->HandleMessage(msg, false); }
auto move = [&rangeManager](entity_id_t ent, MockPositionRgm& pos, fixed x, fixed z) {
pos.m_Pos = CFixedVector3D(x, fixed::Zero(), z);
{ CMessagePositionChanged msg(ent, true, x, z, entity_angle_t::Zero()); rangeManager->HandleMessage(msg, false); }
};
move(100, position, fixed::FromInt(10), fixed::FromInt(10));
move(101, position2, fixed::FromInt(10), fixed::FromInt(15));
std::vector<int> owners;
owners.push_back(1);
// Test 1: With preferMirages = true, we should get the mirage entity when visible
ICmpRangeManager::tag_t query = rangeManager->CreateActiveQuery(
100, // source
fixed::FromInt(0), // minRange
fixed::FromInt(50), // maxRange
owners, // owners
0, // requiredInterface
rangeManager->GetEntityFlagMask("normal"),
true, // accountForSize
true // preferMirages = true
);
rangeManager->EnableActiveQuery(query);
// With reveal map enabled, entity should be visible
rangeManager->SetLosRevealWholeMap(1, true);
{ CMessageUpdate msg(fixed::FromInt(1)); rangeManager->HandleMessage(msg, false); }
std::vector<entity_id_t> nearby = rangeManager->ResetActiveQuery(query);
// Should return either the real entity (101) or a mirage.
// For this test, we just verify something is returned.
TS_ASSERT_EQUALS(nearby.size(), 1);
// Disable reveal map - entity should become hidden
rangeManager->SetLosRevealWholeMap(1, false);
{ CMessageUpdate msg(fixed::FromInt(1)); rangeManager->HandleMessage(msg, false); }
nearby = rangeManager->ResetActiveQuery(query);
// Should return empty (no visible entities)
TS_ASSERT_EQUALS(nearby.size(), 0);
// Re-enable reveal map
rangeManager->SetLosRevealWholeMap(1, true);
{ CMessageUpdate msg(fixed::FromInt(1)); rangeManager->HandleMessage(msg, false); }
nearby = rangeManager->ResetActiveQuery(query);
TS_ASSERT_EQUALS(nearby.size(), 1);
// Test 2: With preferMirages = false (default), visibility filtering is disabled
ICmpRangeManager::tag_t query2 = rangeManager->CreateActiveQuery(
100, // source
fixed::FromInt(0), // minRange
fixed::FromInt(50), // maxRange
owners, // owners
0, // requiredInterface
rangeManager->GetEntityFlagMask("normal"),
true, // accountForSize
false // preferMirages = false
);
rangeManager->EnableActiveQuery(query2);
// With preferMirages = false, entity should be returned even when hidden
rangeManager->SetLosRevealWholeMap(1, false);
{ CMessageUpdate msg(fixed::FromInt(1)); rangeManager->HandleMessage(msg, false); }
nearby = rangeManager->ResetActiveQuery(query2);
// Should return the real entity (101) even though hidden
TS_ASSERT_EQUALS(nearby, std::vector<entity_id_t>{101});
// Clean up
rangeManager->DestroyActiveQuery(query);
rangeManager->DestroyActiveQuery(query2);
}
void test_ParabolicRangeBasic()
{
ComponentTestHelper test(*g_ScriptContext);
ICmpRangeManager* rangeManager = test.Add<ICmpRangeManager>(CID_RangeManager, "", SYSTEM_ENTITY);
const entity_id_t source = 200;
const entity_id_t target = 201;
entity_pos_t range = fixed::FromInt(-3);
entity_pos_t yOrigin = fixed::FromInt(-20);
entity_pos_t range{fixed::FromInt(-3)};
entity_pos_t yOrigin{fixed::FromInt(-20)};
// Invalid range.
TS_ASSERT_EQUALS(cmp->GetEffectiveParabolicRange(source, target, range, yOrigin), range);
TS_ASSERT_EQUALS(rangeManager->GetEffectiveParabolicRange(source, target, range, yOrigin), range);
// No source ICmpPosition.
range = fixed::FromInt(10);
TS_ASSERT_EQUALS(cmp->GetEffectiveParabolicRange(source, target, range, yOrigin), NEVER_IN_RANGE);
TS_ASSERT_EQUALS(rangeManager->GetEffectiveParabolicRange(source, target, range, yOrigin), NEVER_IN_RANGE);
// No target ICmpPosition.
MockPositionRgm cmpSourcePosition;
test.AddMock(source, IID_Position, cmpSourcePosition);
TS_ASSERT_EQUALS(cmp->GetEffectiveParabolicRange(source, target, range, yOrigin), NEVER_IN_RANGE);
TS_ASSERT_EQUALS(rangeManager->GetEffectiveParabolicRange(source, target, range, yOrigin), NEVER_IN_RANGE);
// Too much height difference.
MockPositionRgm cmpTargetPosition;
test.AddMock(target, IID_Position, cmpTargetPosition);
TS_ASSERT_EQUALS(cmp->GetEffectiveParabolicRange(source, target, range, yOrigin), NEVER_IN_RANGE);
TS_ASSERT_EQUALS(rangeManager->GetEffectiveParabolicRange(source, target, range, yOrigin), NEVER_IN_RANGE);
// If no offset we get the range.
range = fixed::FromInt(20);
yOrigin = fixed::Zero();
TS_ASSERT_EQUALS(cmp->GetEffectiveParabolicRange(source, target, range, yOrigin), range);
TS_ASSERT_EQUALS(cmp->GetEffectiveParabolicRange(source, target, fixed::Zero(), yOrigin), fixed::Zero());
TS_ASSERT_EQUALS(rangeManager->GetEffectiveParabolicRange(source, target, range, yOrigin), range);
TS_ASSERT_EQUALS(rangeManager->GetEffectiveParabolicRange(source, target, fixed::Zero(), yOrigin), fixed::Zero());
// Normal case.
// Normal case with yOrigin only (no terrain difference)
yOrigin = fixed::FromInt(5);
range = fixed::FromInt(10);
TS_ASSERT_EQUALS(cmp->GetEffectiveParabolicRange(source, target, range, yOrigin), fixed::FromFloat(14.142136f));
TS_ASSERT_EQUALS(rangeManager->GetEffectiveParabolicRange(source, target, range, yOrigin), fixed::FromFloat(14.142136f));
// Big range.
range = fixed::FromInt(260);
TS_ASSERT_EQUALS(cmp->GetEffectiveParabolicRange(source, target, range, yOrigin), fixed::FromFloat(264.952820f));
TS_ASSERT_EQUALS(rangeManager->GetEffectiveParabolicRange(source, target, range, yOrigin), fixed::FromFloat(264.952820f));
}
void test_ExploreCircle()
@ -367,4 +472,72 @@ public:
cmp->Verify();
}
void test_ParabolicRangeWithTerrain()
{
ComponentTestHelper test(*g_ScriptContext);
ICmpRangeManager* rangeManager = test.Add<ICmpRangeManager>(CID_RangeManager, "", SYSTEM_ENTITY);
const entity_id_t source{200};
const entity_id_t target{201};
MockPositionRgm sourcePos;
MockPositionRgm targetPos;
test.AddMock(source, IID_Position, sourcePos);
test.AddMock(target, IID_Position, targetPos);
const entity_pos_t range{fixed::FromInt(100)};
const entity_pos_t yOrigin{fixed::Zero()};
// Source on high ground (Y=10), target on low ground (Y=0)
sourcePos.m_Pos = CFixedVector3D(fixed::Zero(), fixed::FromInt(10), fixed::Zero());
targetPos.m_Pos = CFixedVector3D(fixed::Zero(), fixed::Zero(), fixed::FromInt(50));
entity_pos_t effective = rangeManager->GetEffectiveParabolicRange(source, target, range, yOrigin);
TS_ASSERT_DELTA(effective.ToFloat(), 109.5445f, 0.01f); // ~109.54
// Source on low ground (Y=0), target on high ground (Y=10)
sourcePos.m_Pos = CFixedVector3D(fixed::Zero(), fixed::Zero(), fixed::Zero());
targetPos.m_Pos = CFixedVector3D(fixed::Zero(), fixed::FromInt(10), fixed::FromInt(50));
effective = rangeManager->GetEffectiveParabolicRange(source, target, range, yOrigin);
TS_ASSERT_DELTA(effective.ToFloat(), 89.4427f, 0.01f); // ~89.44
// Source with height offset (Y=15), target on flat ground (Y=0), with yOrigin
sourcePos.m_Pos = CFixedVector3D(fixed::Zero(), fixed::FromInt(15), fixed::Zero());
targetPos.m_Pos = CFixedVector3D(fixed::Zero(), fixed::Zero(), fixed::FromInt(50));
const entity_pos_t yOrigin2{fixed::FromInt(2)};
effective = rangeManager->GetEffectiveParabolicRange(source, target, range, yOrigin2);
TS_ASSERT_DELTA(effective.ToFloat(), 115.7583f, 0.01f); // ~115.76
}
void test_ParabolicRangeTargetTooHigh()
{
ComponentTestHelper test(*g_ScriptContext);
ICmpRangeManager* rangeManager = test.Add<ICmpRangeManager>(CID_RangeManager, "", SYSTEM_ENTITY);
const entity_id_t source{200};
const entity_id_t target{201};
MockPositionRgm sourcePos;
MockPositionRgm targetPos;
test.AddMock(source, IID_Position, sourcePos);
test.AddMock(target, IID_Position, targetPos);
// Source on flat ground (height=0), target very high (height=30)
sourcePos.m_Pos = CFixedVector3D(fixed::Zero(), fixed::Zero(), fixed::Zero());
targetPos.m_Pos = CFixedVector3D(fixed::Zero(), fixed::FromInt(30), fixed::Zero());
const entity_pos_t range{fixed::FromInt(50)};
const entity_pos_t yOrigin{fixed::Zero()};
// heightDifference = 0 - 30 = -30, range/2 = 25
// -30 < -25 → NEVER_IN_RANGE
TS_ASSERT_EQUALS(rangeManager->GetEffectiveParabolicRange(source, target, range, yOrigin), NEVER_IN_RANGE);
// Target at borderline height (25)
targetPos.m_Pos = CFixedVector3D(fixed::Zero(), fixed::FromInt(25), fixed::Zero());
const entity_pos_t effective = rangeManager->GetEffectiveParabolicRange(source, target, range, yOrigin);
TS_ASSERT_DIFFERS(effective, NEVER_IN_RANGE);
TS_ASSERT_EQUALS(effective, fixed::Zero());
}
};