0ad/binaries/data/mods/public/simulation/components/TurretHolder.js
Atrik a373c0e4ef 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 16:26:22 +02:00

509 lines
15 KiB
JavaScript

/**
* This class holds the functions regarding entities being visible on
* another entity, but tied to their parents location.
*/
class TurretHolder
{
Init()
{
this.turretPoints = [];
const points = this.template.TurretPoints;
for (const point in points)
this.turretPoints.push({
"name": point,
"offset": {
"x": +points[point].X,
"y": +points[point].Y,
"z": +points[point].Z
},
"allowedClasses": points[point].AllowedClasses?._string,
"angle": points[point].Angle ? +points[point].Angle * Math.PI / 180 : null,
"entity": null,
"template": points[point].Template,
"ejectable": "Ejectable" in points[point] ? points[point].Ejectable == "true" : true
});
}
/**
* Add a subunit as specified in the template.
* This function creates an entity and places it on the turret point.
*
* @param {Object} turretPoint - A turret point to (re)create the predefined subunit for.
*
* @return {boolean} - Whether the turret creation has succeeded.
*/
CreateSubunit(turretPointName)
{
const turretPoint = this.TurretPointByName(turretPointName);
if (!turretPoint || turretPoint.entity ||
this.initTurrets?.has(turretPointName) ||
this.reservedTurrets?.has(turretPointName))
return false;
const cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership);
const upgradedTemplate = GetUpgradedTemplate(cmpOwnership.GetOwner(), turretPoint.template);
const ent = Engine.AddEntity(upgradedTemplate);
const cmpEntOwnership = Engine.QueryInterface(ent, IID_Ownership);
cmpEntOwnership?.SetOwner(cmpOwnership.GetOwner());
const cmpTurretable = Engine.QueryInterface(ent, IID_Turretable);
return cmpTurretable?.OccupyTurret(this.entity, turretPoint.name, turretPoint.ejectable) || Engine.DestroyEntity(ent);
}
/**
* @param {string} name - The name of a turret point to reserve, e.g. for promotion.
*/
SetReservedTurretPoint(name)
{
if (!this.reservedTurrets)
this.reservedTurrets = new Set();
this.reservedTurrets.add(name);
}
/**
* @return {Object[]} - An array of the turret points this entity has.
*/
GetTurretPoints()
{
return this.turretPoints;
}
/**
* @param {number} entity - The entity to check for.
* @param {Object} turretPoint - The turret point to use.
* @param {boolean} [forReplacement=false] - Whether this check is for replacement
* (if true, occupied turret points are allowed).
*
* @return {boolean} - Whether the entity is allowed to occupy the specified turret point.
*/
AllowedToOccupyTurretPoint(entity, turretPoint, forReplacement = false)
{
if (!turretPoint || turretPoint.entity && !forReplacement)
return false;
if (!IsOwnedByMutualAllyOfEntity(entity, this.entity))
return false;
if (!turretPoint.allowedClasses)
return true;
const cmpIdentity = Engine.QueryInterface(entity, IID_Identity);
return cmpIdentity && MatchesClassList(cmpIdentity.GetClassesList(), turretPoint.allowedClasses);
}
/**
* @param {number} entity - The entity to check for.
* @return {boolean} - Whether the entity is allowed to occupy any turret point.
*/
CanOccupy(entity)
{
return !!this.turretPoints.find(turretPoint => this.AllowedToOccupyTurretPoint(entity, turretPoint));
}
/**
* Occupy a turret point with the given entity.
* @param {number} entity - The entity to use.
* @param {Object} requestedTurretPoint - Optionally the specific turret point to occupy.
*
* @return {boolean} - Whether the occupation was successful.
*/
OccupyTurretPoint(entity, requestedTurretPoint)
{
const cmpPositionOccupant = Engine.QueryInterface(entity, IID_Position);
if (!cmpPositionOccupant)
return false;
const cmpPositionSelf = Engine.QueryInterface(this.entity, IID_Position);
if (!cmpPositionSelf)
return false;
if (this.OccupiesTurretPoint(entity))
return false;
let turretPoint;
if (requestedTurretPoint)
{
if (this.AllowedToOccupyTurretPoint(entity, requestedTurretPoint))
turretPoint = requestedTurretPoint;
}
else
turretPoint = this.turretPoints.find(turret => !turret.entity && this.AllowedToOccupyTurretPoint(entity, turret));
if (!turretPoint)
return false;
turretPoint.entity = entity;
// Angle of turrets:
// Renamed entities (turretPoint != undefined) should keep their angle.
// Otherwise if an angle is given in the turretPoint, use it.
// If no such angle given (usually walls for which outside/inside not well defined), we keep
// the current angle as it was used for garrisoning and thus quite often was from inside to
// outside, except when garrisoning from outWorld where we take as default PI.
if (!turretPoint && turretPoint.angle != null)
cmpPositionOccupant.SetYRotation(cmpPositionSelf.GetRotation().y + turretPoint.angle);
else if (!turretPoint && !cmpPosition.IsInWorld())
cmpPositionOccupant.SetYRotation(cmpPositionSelf.GetRotation().y + Math.PI);
cmpPositionOccupant.SetTurretParent(this.entity, turretPoint.offset);
Engine.PostMessage(this.entity, MT_TurretsChanged, {
"added": [entity],
"removed": []
});
return true;
}
/**
* @param {number} entity - The entityID of the entity.
* @param {String} turretName - The name of the turret point to occupy.
* @return {boolean} - Whether the occupation has succeeded.
*/
OccupyNamedTurretPoint(entity, turretName)
{
return this.OccupyTurretPoint(entity, this.TurretPointByName(turretName));
}
/**
* @param {string} turretPointName - The name of the requested turret point.
* @return {Object} - The requested turret point.
*/
TurretPointByName(turretPointName)
{
return this.turretPoints.find(turret => turret.name == turretPointName);
}
/**
* Remove the entity from a turret.
* @param {number} entity - The specific entity to eject.
* @param {boolean} forced - Whether ejection is forced (e.g. due to death or renaming).
* @param {Object} turret - Optionally the turret to abandon.
*
* @return {boolean} - Whether the entity succesfully left us.
*/
LeaveTurretPoint(entity, forced, requestedTurretPoint)
{
let turretPoint;
if (requestedTurretPoint)
{
if (requestedTurretPoint.entity == entity)
turretPoint = requestedTurretPoint;
}
else
turretPoint = this.GetOccupiedTurretPoint(entity);
if (!turretPoint || (!turretPoint.ejectable && !forced))
return false;
turretPoint.entity = null;
Engine.PostMessage(this.entity, MT_TurretsChanged, {
"added": [],
"removed": [entity]
});
return true;
}
/**
* @param {number} entity - The entity's id.
* @param {Object} turret - Optionally the turret to check.
*
* @return {boolean} - Whether the entity is positioned on a turret of this entity.
*/
OccupiesTurretPoint(entity, requestedTurretPoint)
{
return requestedTurretPoint ? requestedTurretPoint.entity == entity :
!!this.GetOccupiedTurretPoint(entity);
}
/**
* @param {number} entity - The entity's id.
* @return {Object} - The turret this entity is positioned on, if applicable.
*/
GetOccupiedTurretPoint(entity)
{
return this.turretPoints.find(turretPoint => turretPoint.entity == entity);
}
/**
* @param {number} entity - The entity's id.
* @return {Object} - The turret this entity is positioned on, if applicable.
*/
GetOccupiedTurretPointName(entity)
{
const turret = this.GetOccupiedTurretPoint(entity);
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.
*/
GetEntities()
{
const entities = [];
for (const turretPoint of this.turretPoints)
if (turretPoint.entity)
entities.push(turretPoint.entity);
return entities;
}
/**
* @return {boolean} - Whether all the turret points are occupied.
*/
IsFull()
{
return !!this.turretPoints.find(turretPoint => turretPoint.entity == null);
}
/**
* @return {Object} - Max and min ranges at which entities can occupy any turret.
*/
LoadingRange()
{
return { "min": 0, "max": +(this.template.LoadingRange || 2) };
}
/**
* @param {number} ent - The entity ID of the turret to be potentially picked up.
* @return {boolean} - Whether this entity can pick the specified entity up.
*/
CanPickup(ent)
{
if (!this.template.Pickup || this.IsFull())
return false;
const cmpOwner = Engine.QueryInterface(this.entity, IID_Ownership);
return !!cmpOwner && IsOwnedByPlayer(cmpOwner.GetOwner(), ent);
}
/**
* @param {number[]} entities - The entities to ask to leave or to kill.
*/
EjectOrKill(entities)
{
const removedEntities = [];
for (const entity of entities)
{
const cmpTurretable = Engine.QueryInterface(entity, IID_Turretable);
if (!cmpTurretable || !cmpTurretable.LeaveTurret(true))
{
const cmpHealth = Engine.QueryInterface(entity, IID_Health);
if (cmpHealth)
cmpHealth.Kill();
else
Engine.DestroyEntity(entity);
removedEntities.push(entity);
}
}
if (removedEntities.length)
Engine.PostMessage(this.entity, MT_TurretsChanged, {
"added": [],
"removed": removedEntities
});
}
/**
* Sets an init turret, present from game start. (E.g. set in Atlas.)
* @param {String} turretName - The name of the turret point to be used.
* @param {number} entity - The entity-ID to be placed.
*/
SetInitEntity(turretName, entity)
{
if (!this.initTurrets)
this.initTurrets = new Map();
if (this.initTurrets.has(turretName))
warn("The turret position " + turretName + " of entity " +
this.entity + " is already set! Overwriting.");
this.initTurrets.set(turretName, entity);
}
/**
* Update list of turreted entities when a game inits.
*/
OnGlobalSkirmishReplacerReplaced(msg)
{
if (!this.initTurrets)
return;
if (msg.entity == this.entity)
{
const cmpTurretHolder = Engine.QueryInterface(msg.newentity, IID_TurretHolder);
if (cmpTurretHolder)
cmpTurretHolder.initTurrets = this.initTurrets;
}
else
{
const entityIndex = this.initTurrets.indexOf(msg.entity);
if (entityIndex != -1)
this.initTurrets[entityIndex] = msg.newentity;
}
}
/**
* Initialise turreted units.
*/
OnGlobalInitGame(msg)
{
if (!this.initTurrets)
return;
for (const [turretPointName, entity] of this.initTurrets)
{
const cmpTurretable = Engine.QueryInterface(entity, IID_Turretable);
if (!cmpTurretable || !cmpTurretable.OccupyTurret(this.entity, turretPointName, this.TurretPointByName(turretPointName).ejectable))
warn("Entity " + entity + " could not occupy the turret point " +
turretPointName + " of turret holder " + this.entity + ".");
}
delete this.initTurrets;
}
/**
* @param {Object} msg - { "entity": number, "newentity": number }.
*/
OnEntityRenamed(msg)
{
for (const entity of this.GetEntities())
{
const cmpTurretable = Engine.QueryInterface(entity, IID_Turretable);
if (!cmpTurretable)
continue;
const currentPoint = this.GetOccupiedTurretPointName(entity);
cmpTurretable.LeaveTurret(true);
cmpTurretable.OccupyTurret(msg.newentity, currentPoint);
}
}
/**
* @param {Object} msg - { "entity": number, "from": number, "to": number }.
*/
OnOwnershipChanged(msg)
{
if (msg.to === INVALID_PLAYER)
{
this.EjectOrKill(this.GetEntities());
return;
}
for (const point of this.turretPoints)
{
// If we were created, create any subunits now.
// This has to be done here (instead of on Init)
// for Ownership ought to be initialised.
if (point.template && msg.from === INVALID_PLAYER)
{
this.CreateSubunit(point.name);
continue;
}
if (!point.entity)
continue;
if (!point.ejectable)
{
const cmpTurretOwnership = Engine.QueryInterface(point.entity, IID_Ownership);
if (cmpTurretOwnership)
cmpTurretOwnership.SetOwner(msg.to);
}
else if (!IsOwnedByMutualAllyOfEntity(point.entity, this.entity))
{
const cmpTurretable = Engine.QueryInterface(point.entity, IID_Turretable);
if (cmpTurretable)
cmpTurretable.LeaveTurret();
}
}
delete this.reservedTurrets;
}
}
TurretHolder.prototype.Schema =
"<element name='TurretPoints' a:help='Points that will be used to visibly garrison a unit.'>" +
"<oneOrMore>" +
"<element a:help='Element containing the offset coordinates.'>" +
"<anyName/>" +
"<interleave>" +
"<element name='X'>" +
"<data type='decimal'/>" +
"</element>" +
"<element name='Y'>" +
"<data type='decimal'/>" +
"</element>" +
"<element name='Z'>" +
"<data type='decimal'/>" +
"</element>" +
"<optional>" +
"<interleave>" +
"<element name='Template'>" +
"<text/>" +
"</element>" +
"<element name='Ejectable' a:help='Whether this template is tied to the turret position (i.e. not allowed to leave the turret point).'>" +
"<data type='boolean'/>" +
"</element>" +
"</interleave>" +
"</optional>" +
"<optional>" +
"<element name='AllowedClasses' a:help='If specified, only entities matching the given classes will be able to use this turret.'>" +
"<attribute name='datatype'>" +
"<value>tokens</value>" +
"</attribute>" +
"<text/>" +
"</element>" +
"</optional>"+
"<optional>" +
"<element name='Angle' a:help='Angle in degrees relative to the turretHolder direction.'>" +
"<data type='decimal'/>" +
"</element>" +
"</optional>" +
"</interleave>" +
"</element>" +
"</oneOrMore>" +
"</element>" +
"<optional>" +
"<element name='LoadingRange' a:help='The maximum distance from this holder at which entities are allowed to occupy a turret point. Should be about 2.0 for land entities and preferably greater for ships.'>" +
"<ref name='nonNegativeDecimal'/>" +
"</element>" +
"</optional>" +
"<optional>" +
"<element name='Pickup' a:help='This entity will try to move to pick up units to be turreted.'>" +
"<data type='boolean'/>" +
"</element>" +
"</optional>";
Engine.RegisterComponentType(IID_TurretHolder, "TurretHolder", TurretHolder);