mirror of
https://gitea.wildfiregames.com/0ad/0ad
synced 2026-07-06 06:55:48 -07:00
Units sometimes ignored targets that entered their LoS. The cause isd0fc8ff67d: range queries returned units farther away, and those units might actually be out of range if distance is computed center-to-center, which both UnitAI and LOS do. This meant that code relying on range query updates was possibly broken, and indeed units missed things (see ticket). This introduces a boolean to switch between pre-d0fc8ff67d behaviour (entity-as-point, center-to-center range queries) and post-d0fc8ff67d (entities-as-circumscribing-circle, edge-to-edge range queries). The former is used for UnitAI (where the new behaviour bugged), auras (where varying structure sizes made it awkward) and build restrictions(which simply did not really need it). Reverts7f1ee23d88,050c5401b1(with the exception of the iber monument footprint), and the template changes ind0fc8ff67ditself. It also reduces alertRaiser ranges slightly, this was missed in the original diff. #3381 is not reopened as BuildingAI still uses the new range queries. Reported by: Freagarach Comments By: Angen Fixes #5968 Differential Revision: https://code.wildfiregames.com/D3456 This was SVN commit r24776.
408 lines
11 KiB
JavaScript
408 lines
11 KiB
JavaScript
// Number of rounds of firing per 2 seconds.
|
|
const roundCount = 10;
|
|
const attackType = "Ranged";
|
|
|
|
function BuildingAI() {}
|
|
|
|
BuildingAI.prototype.Schema =
|
|
"<element name='DefaultArrowCount'>" +
|
|
"<data type='nonNegativeInteger'/>" +
|
|
"</element>" +
|
|
"<optional>" +
|
|
"<element name='MaxArrowCount' a:help='Limit the number of arrows to a certain amount'>" +
|
|
"<data type='nonNegativeInteger'/>" +
|
|
"</element>" +
|
|
"</optional>" +
|
|
"<element name='GarrisonArrowMultiplier'>" +
|
|
"<ref name='nonNegativeDecimal'/>" +
|
|
"</element>" +
|
|
"<element name='GarrisonArrowClasses' a:help='Add extra arrows for this class list'>" +
|
|
"<text/>" +
|
|
"</element>";
|
|
|
|
BuildingAI.prototype.MAX_PREFERENCE_BONUS = 2;
|
|
|
|
BuildingAI.prototype.Init = function()
|
|
{
|
|
this.currentRound = 0;
|
|
this.archersGarrisoned = 0;
|
|
this.arrowsLeft = 0;
|
|
this.targetUnits = [];
|
|
};
|
|
|
|
BuildingAI.prototype.OnGarrisonedUnitsChanged = function()
|
|
{
|
|
this.RecalculateProjectileCount();
|
|
};
|
|
|
|
BuildingAI.prototype.OnTurretsChanged = function()
|
|
{
|
|
this.RecalculateProjectileCount();
|
|
};
|
|
|
|
BuildingAI.prototype.RecalculateProjectileCount = function()
|
|
{
|
|
this.archersGarrisoned = 0;
|
|
let classes = this.template.GarrisonArrowClasses;
|
|
|
|
let cmpTurretHolder = Engine.QueryInterface(this.entity, IID_TurretHolder);
|
|
let cmpGarrisonHolder = Engine.QueryInterface(this.entity, IID_GarrisonHolder);
|
|
for (let ent of cmpGarrisonHolder.GetEntities())
|
|
{
|
|
// Only count non-visible garrisoned entities towards extra arrows.
|
|
if (cmpTurretHolder && cmpTurretHolder.OccupiesTurret(ent))
|
|
continue;
|
|
|
|
let cmpIdentity = Engine.QueryInterface(ent, IID_Identity);
|
|
if (!cmpIdentity)
|
|
continue;
|
|
|
|
if (MatchesClassList(cmpIdentity.GetClassesList(), classes))
|
|
++this.archersGarrisoned;
|
|
}
|
|
};
|
|
|
|
BuildingAI.prototype.OnOwnershipChanged = function(msg)
|
|
{
|
|
this.targetUnits = [];
|
|
this.SetupRangeQuery();
|
|
this.SetupGaiaRangeQuery();
|
|
};
|
|
|
|
BuildingAI.prototype.OnDiplomacyChanged = function(msg)
|
|
{
|
|
if (!IsOwnedByPlayer(msg.player, this.entity))
|
|
return;
|
|
|
|
// Remove maybe now allied/neutral units.
|
|
this.targetUnits = [];
|
|
this.SetupRangeQuery();
|
|
this.SetupGaiaRangeQuery();
|
|
};
|
|
|
|
BuildingAI.prototype.OnDestroy = function()
|
|
{
|
|
if (this.timer)
|
|
{
|
|
let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer);
|
|
cmpTimer.CancelTimer(this.timer);
|
|
this.timer = undefined;
|
|
}
|
|
|
|
// Clean up range queries.
|
|
let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager);
|
|
if (this.enemyUnitsQuery)
|
|
cmpRangeManager.DestroyActiveQuery(this.enemyUnitsQuery);
|
|
if (this.gaiaUnitsQuery)
|
|
cmpRangeManager.DestroyActiveQuery(this.gaiaUnitsQuery);
|
|
};
|
|
|
|
/**
|
|
* React on Attack value modifications, as it might influence the range.
|
|
*/
|
|
BuildingAI.prototype.OnValueModification = function(msg)
|
|
{
|
|
if (msg.component != "Attack")
|
|
return;
|
|
|
|
this.targetUnits = [];
|
|
this.SetupRangeQuery();
|
|
this.SetupGaiaRangeQuery();
|
|
};
|
|
|
|
/**
|
|
* Setup the Range Query to detect units coming in & out of range.
|
|
*/
|
|
BuildingAI.prototype.SetupRangeQuery = function()
|
|
{
|
|
var cmpAttack = Engine.QueryInterface(this.entity, IID_Attack);
|
|
if (!cmpAttack)
|
|
return;
|
|
|
|
var cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager);
|
|
if (this.enemyUnitsQuery)
|
|
{
|
|
cmpRangeManager.DestroyActiveQuery(this.enemyUnitsQuery);
|
|
this.enemyUnitsQuery = undefined;
|
|
}
|
|
|
|
var cmpPlayer = QueryOwnerInterface(this.entity);
|
|
if (!cmpPlayer)
|
|
return;
|
|
|
|
var enemies = cmpPlayer.GetEnemies();
|
|
// Remove gaia.
|
|
if (enemies.length && enemies[0] == 0)
|
|
enemies.shift();
|
|
|
|
if (!enemies.length)
|
|
return;
|
|
|
|
var range = cmpAttack.GetRange(attackType);
|
|
// This takes entity sizes into accounts, so no need to compensate for structure size.
|
|
this.enemyUnitsQuery = cmpRangeManager.CreateActiveParabolicQuery(
|
|
this.entity, range.min, range.max, range.elevationBonus,
|
|
enemies, IID_Resistance, cmpRangeManager.GetEntityFlagMask("normal"));
|
|
|
|
cmpRangeManager.EnableActiveQuery(this.enemyUnitsQuery);
|
|
};
|
|
|
|
// Set up a range query for Gaia units within LOS range which can be attacked.
|
|
// This should be called whenever our ownership changes.
|
|
BuildingAI.prototype.SetupGaiaRangeQuery = function()
|
|
{
|
|
var cmpAttack = Engine.QueryInterface(this.entity, IID_Attack);
|
|
if (!cmpAttack)
|
|
return;
|
|
|
|
var cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager);
|
|
if (this.gaiaUnitsQuery)
|
|
{
|
|
cmpRangeManager.DestroyActiveQuery(this.gaiaUnitsQuery);
|
|
this.gaiaUnitsQuery = undefined;
|
|
}
|
|
|
|
var cmpPlayer = QueryOwnerInterface(this.entity);
|
|
if (!cmpPlayer || !cmpPlayer.IsEnemy(0))
|
|
return;
|
|
|
|
var range = cmpAttack.GetRange(attackType);
|
|
|
|
// 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, range.elevationBonus,
|
|
[0], IID_Attack, cmpRangeManager.GetEntityFlagMask("normal"));
|
|
|
|
cmpRangeManager.EnableActiveQuery(this.gaiaUnitsQuery);
|
|
};
|
|
|
|
/**
|
|
* Called when units enter or leave range.
|
|
*/
|
|
BuildingAI.prototype.OnRangeUpdate = function(msg)
|
|
{
|
|
|
|
var cmpAttack = Engine.QueryInterface(this.entity, IID_Attack);
|
|
if (!cmpAttack)
|
|
return;
|
|
|
|
// Target enemy units except non-dangerous animals.
|
|
if (msg.tag == this.gaiaUnitsQuery)
|
|
{
|
|
msg.added = msg.added.filter(e => {
|
|
let cmpUnitAI = Engine.QueryInterface(e, IID_UnitAI);
|
|
return cmpUnitAI && (!cmpUnitAI.IsAnimal() || cmpUnitAI.IsDangerousAnimal());
|
|
});
|
|
}
|
|
else if (msg.tag != this.enemyUnitsQuery)
|
|
return;
|
|
|
|
// Add new targets.
|
|
for (let entity of msg.added)
|
|
if (cmpAttack.CanAttack(entity))
|
|
this.targetUnits.push(entity);
|
|
|
|
// Remove targets outside of vision-range.
|
|
for (let entity of msg.removed)
|
|
{
|
|
let index = this.targetUnits.indexOf(entity);
|
|
if (index > -1)
|
|
this.targetUnits.splice(index, 1);
|
|
}
|
|
|
|
if (this.targetUnits.length)
|
|
this.StartTimer();
|
|
};
|
|
|
|
BuildingAI.prototype.StartTimer = function()
|
|
{
|
|
if (this.timer)
|
|
return;
|
|
|
|
var cmpAttack = Engine.QueryInterface(this.entity, IID_Attack);
|
|
if (!cmpAttack)
|
|
return;
|
|
|
|
var cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer);
|
|
var attackTimers = cmpAttack.GetTimers(attackType);
|
|
|
|
this.timer = cmpTimer.SetInterval(this.entity, IID_BuildingAI, "FireArrows",
|
|
attackTimers.prepare, attackTimers.repeat / roundCount, null);
|
|
};
|
|
|
|
BuildingAI.prototype.GetDefaultArrowCount = function()
|
|
{
|
|
var arrowCount = +this.template.DefaultArrowCount;
|
|
return Math.round(ApplyValueModificationsToEntity("BuildingAI/DefaultArrowCount", arrowCount, this.entity));
|
|
};
|
|
|
|
BuildingAI.prototype.GetMaxArrowCount = function()
|
|
{
|
|
if (!this.template.MaxArrowCount)
|
|
return Infinity;
|
|
|
|
let maxArrowCount = +this.template.MaxArrowCount;
|
|
return Math.round(ApplyValueModificationsToEntity("BuildingAI/MaxArrowCount", maxArrowCount, this.entity));
|
|
};
|
|
|
|
BuildingAI.prototype.GetGarrisonArrowMultiplier = function()
|
|
{
|
|
var arrowMult = +this.template.GarrisonArrowMultiplier;
|
|
return ApplyValueModificationsToEntity("BuildingAI/GarrisonArrowMultiplier", arrowMult, this.entity);
|
|
};
|
|
|
|
BuildingAI.prototype.GetGarrisonArrowClasses = function()
|
|
{
|
|
var string = this.template.GarrisonArrowClasses;
|
|
if (string)
|
|
return string.split(/\s+/);
|
|
return [];
|
|
};
|
|
|
|
/**
|
|
* Returns the number of arrows which needs to be fired.
|
|
* DefaultArrowCount + Garrisoned Archers (i.e., any unit capable
|
|
* of shooting arrows from inside buildings).
|
|
*/
|
|
BuildingAI.prototype.GetArrowCount = function()
|
|
{
|
|
let count = this.GetDefaultArrowCount() +
|
|
Math.round(this.archersGarrisoned * this.GetGarrisonArrowMultiplier());
|
|
|
|
return Math.min(count, this.GetMaxArrowCount());
|
|
};
|
|
|
|
BuildingAI.prototype.SetUnitAITarget = function(ent)
|
|
{
|
|
this.unitAITarget = ent;
|
|
if (ent)
|
|
this.StartTimer();
|
|
};
|
|
|
|
/**
|
|
* Fire arrows with random temporal distribution on prefered targets.
|
|
* Called 'roundCount' times every 'RepeatTime' seconds when there are units in the range.
|
|
*/
|
|
BuildingAI.prototype.FireArrows = function()
|
|
{
|
|
if (!this.targetUnits.length && !this.unitAITarget)
|
|
{
|
|
if (!this.timer)
|
|
return;
|
|
|
|
let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer);
|
|
cmpTimer.CancelTimer(this.timer);
|
|
this.timer = undefined;
|
|
return;
|
|
}
|
|
|
|
let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack);
|
|
if (!cmpAttack)
|
|
return;
|
|
|
|
if (this.currentRound > roundCount - 1)
|
|
this.currentRound = 0;
|
|
|
|
if (this.currentRound == 0)
|
|
this.arrowsLeft = this.GetArrowCount();
|
|
|
|
let arrowsToFire = 0;
|
|
if (this.currentRound == roundCount - 1)
|
|
arrowsToFire = this.arrowsLeft;
|
|
else
|
|
arrowsToFire = Math.min(
|
|
randIntInclusive(0, 2 * this.GetArrowCount() / roundCount),
|
|
this.arrowsLeft
|
|
);
|
|
|
|
if (arrowsToFire <= 0)
|
|
{
|
|
++this.currentRound;
|
|
return;
|
|
}
|
|
|
|
// Add targets to a weighted list, to allow preferences.
|
|
let targets = new WeightedList();
|
|
let maxPreference = this.MAX_PREFERENCE_BONUS;
|
|
let addTarget = function(target)
|
|
{
|
|
let preference = cmpAttack.GetPreference(target);
|
|
let weight = 1;
|
|
|
|
if (preference !== null && preference !== undefined)
|
|
weight += maxPreference / (1 + preference);
|
|
|
|
targets.push(target, weight);
|
|
};
|
|
|
|
// Add the UnitAI target separately, as the UnitMotion and RangeManager implementations differ.
|
|
if (this.unitAITarget && this.targetUnits.indexOf(this.unitAITarget) == -1)
|
|
addTarget(this.unitAITarget);
|
|
for (let target of this.targetUnits)
|
|
addTarget(target);
|
|
|
|
// The obstruction manager performs approximate range checks.
|
|
// so we need to verify them here.
|
|
// TODO: perhaps an optional 'precise' mode to range queries would be more performant.
|
|
let cmpObstructionManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager);
|
|
let range = cmpAttack.GetRange(attackType);
|
|
|
|
let thisCmpPosition = Engine.QueryInterface(this.entity, IID_Position);
|
|
if (!thisCmpPosition.IsInWorld())
|
|
return;
|
|
let s = thisCmpPosition.GetPosition();
|
|
|
|
let firedArrows = 0;
|
|
while (firedArrows < arrowsToFire && targets.length())
|
|
{
|
|
let selectedIndex = targets.randomIndex();
|
|
let selectedTarget = targets.itemAt(selectedIndex);
|
|
|
|
let targetCmpPosition = Engine.QueryInterface(selectedTarget, IID_Position);
|
|
if (targetCmpPosition && targetCmpPosition.IsInWorld() && this.CheckTargetVisible(selectedTarget))
|
|
{
|
|
// Parabolic range compuation is the same as in UnitAI's MoveToTargetAttackRange.
|
|
// h is positive when I'm higher than the target.
|
|
let h = s.y - targetCmpPosition.GetPosition().y + range.elevationBonus;
|
|
if (h > -range.max / 2 && cmpObstructionManager.IsInTargetRange(
|
|
this.entity,
|
|
selectedTarget,
|
|
range.min,
|
|
Math.sqrt(Math.square(range.max) + 2 * range.max * h), false))
|
|
{
|
|
cmpAttack.PerformAttack(attackType, selectedTarget);
|
|
PlaySound("attack_" + attackType.toLowerCase(), this.entity);
|
|
++firedArrows;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Could not attack target, try a different target.
|
|
targets.removeAt(selectedIndex);
|
|
}
|
|
|
|
this.arrowsLeft -= firedArrows;
|
|
++this.currentRound;
|
|
};
|
|
|
|
/**
|
|
* Returns true if the target entity is visible through the FoW/SoD.
|
|
*/
|
|
BuildingAI.prototype.CheckTargetVisible = function(target)
|
|
{
|
|
var cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership);
|
|
if (!cmpOwnership)
|
|
return false;
|
|
|
|
// Entities that are hidden and miraged are considered visible.
|
|
var cmpFogging = Engine.QueryInterface(target, IID_Fogging);
|
|
if (cmpFogging && cmpFogging.IsMiraged(cmpOwnership.GetOwner()))
|
|
return true;
|
|
|
|
// Either visible directly, or visible in fog.
|
|
let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager);
|
|
return cmpRangeManager.GetLosVisibility(target, cmpOwnership.GetOwner()) != "hidden";
|
|
};
|
|
|
|
Engine.RegisterComponentType(IID_BuildingAI, "BuildingAI", BuildingAI);
|