mirror of
https://gitea.wildfiregames.com/0ad/0ad
synced 2026-06-16 05:13:58 -07:00
Units should not be able to attack entities with "hidden" visibility. Visible and fogged (mirage/retainInFog) targets remain attackable.
925 lines
32 KiB
JavaScript
925 lines
32 KiB
JavaScript
function Attack() {}
|
|
|
|
var g_AttackTypes = ["Melee", "Ranged", "Capture"];
|
|
|
|
Attack.prototype.preferredClassesSchema =
|
|
"<optional>" +
|
|
"<element name='PreferredClasses' a:help='Space delimited list of classes preferred for attacking. If an entity has any of these classes, it is preferred. The classes are in descending order of preference'>" +
|
|
"<attribute name='datatype'>" +
|
|
"<value>tokens</value>" +
|
|
"</attribute>" +
|
|
"<text/>" +
|
|
"</element>" +
|
|
"</optional>";
|
|
|
|
Attack.prototype.restrictedClassesSchema =
|
|
"<optional>" +
|
|
"<element name='RestrictedClasses' a:help='Space delimited list of classes that cannot be attacked by this entity. If target entity has any of these classes, it cannot be attacked'>" +
|
|
"<attribute name='datatype'>" +
|
|
"<value>tokens</value>" +
|
|
"</attribute>" +
|
|
"<text/>" +
|
|
"</element>" +
|
|
"</optional>";
|
|
|
|
Attack.prototype.Schema =
|
|
"<a:help>Controls the attack abilities and strengths of the unit.</a:help>" +
|
|
"<a:example>" +
|
|
"<Melee>" +
|
|
"<AttackName>Spear</AttackName>" +
|
|
"<Damage>" +
|
|
"<Hack>10.0</Hack>" +
|
|
"<Pierce>0.0</Pierce>" +
|
|
"<Crush>5.0</Crush>" +
|
|
"</Damage>" +
|
|
"<MaxRange>4.0</MaxRange>" +
|
|
"<RepeatTime>1000</RepeatTime>" +
|
|
"<Bonuses>" +
|
|
"<Bonus1>" +
|
|
"<Civ>achae</Civ>" +
|
|
"<Classes>Infantry</Classes>" +
|
|
"<Multiplier>1.5</Multiplier>" +
|
|
"</Bonus1>" +
|
|
"<BonusCavMelee>" +
|
|
"<Classes>Cavalry Melee</Classes>" +
|
|
"<Multiplier>1.5</Multiplier>" +
|
|
"</BonusCavMelee>" +
|
|
"</Bonuses>" +
|
|
"<RestrictedClasses datatype=\"tokens\">Champion</RestrictedClasses>" +
|
|
"<PreferredClasses datatype=\"tokens\">Cavalry Infantry</PreferredClasses>" +
|
|
"</Melee>" +
|
|
"<Ranged>" +
|
|
"<AttackName>Bow</AttackName>" +
|
|
"<Damage>" +
|
|
"<Hack>0.0</Hack>" +
|
|
"<Pierce>10.0</Pierce>" +
|
|
"<Crush>0.0</Crush>" +
|
|
"</Damage>" +
|
|
"<MaxRange>44.0</MaxRange>" +
|
|
"<MinRange>20.0</MinRange>" +
|
|
"<Origin>" +
|
|
"<X>0</X>" +
|
|
"<Y>10.0</Y>" +
|
|
"<Z>0</Z>" +
|
|
"</Origin>" +
|
|
"<PrepareTime>800</PrepareTime>" +
|
|
"<RepeatTime>1600</RepeatTime>" +
|
|
"<EffectDelay>1000</EffectDelay>" +
|
|
"<Bonuses>" +
|
|
"<Bonus1>" +
|
|
"<Classes>Cavalry</Classes>" +
|
|
"<Multiplier>2</Multiplier>" +
|
|
"</Bonus1>" +
|
|
"</Bonuses>" +
|
|
"<Projectile>" +
|
|
"<Speed>50.0</Speed>" +
|
|
"<Spread>2.5</Spread>" +
|
|
"<ActorName>props/units/weapons/rock_flaming.xml</ActorName>" +
|
|
"<ImpactActorName>props/units/weapons/rock_explosion.xml</ImpactActorName>" +
|
|
"<ImpactAnimationLifetime>0.1</ImpactAnimationLifetime>" +
|
|
"<FriendlyFire>false</FriendlyFire>" +
|
|
"</Projectile>" +
|
|
"<RestrictedClasses datatype=\"tokens\">Champion</RestrictedClasses>" +
|
|
"<Splash>" +
|
|
"<Shape>Circular</Shape>" +
|
|
"<Range>20</Range>" +
|
|
"<FriendlyFire>false</FriendlyFire>" +
|
|
"<Damage>" +
|
|
"<Hack>0.0</Hack>" +
|
|
"<Pierce>10.0</Pierce>" +
|
|
"<Crush>0.0</Crush>" +
|
|
"</Damage>" +
|
|
"</Splash>" +
|
|
"</Ranged>" +
|
|
"<Slaughter>" +
|
|
"<Damage>" +
|
|
"<Hack>1000.0</Hack>" +
|
|
"<Pierce>0.0</Pierce>" +
|
|
"<Crush>0.0</Crush>" +
|
|
"</Damage>" +
|
|
"<RepeatTime>1000</RepeatTime>" +
|
|
"<MaxRange>4.0</MaxRange>" +
|
|
"</Slaughter>" +
|
|
"</a:example>" +
|
|
"<oneOrMore>" +
|
|
"<element>" +
|
|
"<anyName a:help='Currently one of Melee, Ranged, Capture or Slaughter.'/>" +
|
|
"<interleave>" +
|
|
"<element name='AttackName' a:help='Name of the attack, to be displayed in the GUI.'>" +
|
|
"<optional>" +
|
|
"<attribute name='context'>" +
|
|
"<text/>" +
|
|
"</attribute>" +
|
|
"</optional>" +
|
|
"<optional>" +
|
|
"<attribute name='comment'>" +
|
|
"<text/>" +
|
|
"</attribute>" +
|
|
"</optional>" +
|
|
"<text/>" +
|
|
"</element>" +
|
|
AttackHelper.BuildAttackEffectsSchema() +
|
|
"<element name='MaxRange' a:help='Maximum attack range (in meters)'><ref name='nonNegativeDecimal'/></element>" +
|
|
"<optional>" +
|
|
"<element name='MinRange' a:help='Minimum attack range (in meters). Defaults to 0.'><ref name='nonNegativeDecimal'/></element>" +
|
|
"</optional>" +
|
|
"<optional>"+
|
|
"<element name='Origin' a:help='The offset from which the attack occurs, relative to the entity position. Defaults to {0,0,0}.'>" +
|
|
"<interleave>" +
|
|
"<element name='X'>" +
|
|
"<ref name='nonNegativeDecimal'/>" +
|
|
"</element>" +
|
|
"<element name='Y'>" +
|
|
"<ref name='nonNegativeDecimal'/>" +
|
|
"</element>" +
|
|
"<element name='Z'>" +
|
|
"<ref name='nonNegativeDecimal'/>" +
|
|
"</element>" +
|
|
"</interleave>" +
|
|
"</element>" +
|
|
"</optional>" +
|
|
"<optional>" +
|
|
"<element name='RangeOverlay'>" +
|
|
"<interleave>" +
|
|
"<element name='LineTexture'><text/></element>" +
|
|
"<element name='LineTextureMask'><text/></element>" +
|
|
"<element name='LineThickness'><ref name='nonNegativeDecimal'/></element>" +
|
|
"</interleave>" +
|
|
"</element>" +
|
|
"</optional>" +
|
|
"<optional>" +
|
|
"<element name='PrepareTime' a:help='Time from the start of the attack command until the attack actually occurs (in milliseconds). This value relative to RepeatTime should closely match the \"event\" point in the actor's attack animation. Defaults to 0.'>" +
|
|
"<data type='nonNegativeInteger'/>" +
|
|
"</element>" +
|
|
"</optional>" +
|
|
"<element name='RepeatTime' a:help='Time between attacks (in milliseconds). The attack animation will be stretched to match this time'>" + // TODO: it shouldn't be stretched
|
|
"<data type='positiveInteger'/>" +
|
|
"</element>" +
|
|
"<optional>" +
|
|
"<element name='EffectDelay' a:help='Delay of applying the effects, in milliseconds after the attack has landed. Defaults to 0.'><ref name='nonNegativeDecimal'/></element>" +
|
|
"</optional>" +
|
|
"<optional>" +
|
|
"<element name='Splash'>" +
|
|
"<interleave>" +
|
|
"<element name='Shape' a:help='Shape of the splash damage, can be circular or linear'><text/></element>" +
|
|
"<element name='Range' a:help='Size of the area affected by the splash'><ref name='nonNegativeDecimal'/></element>" +
|
|
"<element name='FriendlyFire' a:help='Whether the splash damage can hurt non enemy units'><data type='boolean'/></element>" +
|
|
AttackHelper.BuildAttackEffectsSchema() +
|
|
"</interleave>" +
|
|
"</element>" +
|
|
"</optional>" +
|
|
"<optional>" +
|
|
"<element name='Projectile'>" +
|
|
"<interleave>" +
|
|
"<element name='Speed' a:help='Speed of projectiles (in meters per second).'>" +
|
|
"<ref name='positiveDecimal'/>" +
|
|
"</element>" +
|
|
"<element name='Spread' a:help='Standard deviation of the bivariate normal distribution of hits at 100 meters. A disk at 100 meters from the attacker with this radius (2x this radius, 3x this radius) is expected to include the landing points of 39.3% (86.5%, 98.9%) of the rounds.'><ref name='nonNegativeDecimal'/></element>" +
|
|
"<element name='Gravity' a:help='The gravity affecting the projectile. This affects the shape of the flight curve.'>" +
|
|
"<ref name='nonNegativeDecimal'/>" +
|
|
"</element>" +
|
|
"<element name='FriendlyFire' a:help='Whether stray missiles can hurt non enemy units.'><data type='boolean'/></element>" +
|
|
"<optional>" +
|
|
"<element name='LaunchPoint' a:help='Delta from the unit position where to launch the projectile.'>" +
|
|
"<attribute name='y'>" +
|
|
"<data type='decimal'/>" +
|
|
"</attribute>" +
|
|
"</element>" +
|
|
"</optional>" +
|
|
"<optional>" +
|
|
"<element name='ActorName' a:help='actor of the projectile animation.'>" +
|
|
"<text/>" +
|
|
"</element>" +
|
|
"</optional>" +
|
|
"<optional>" +
|
|
"<element name='ImpactActorName' a:help='actor of the projectile impact animation'>" +
|
|
"<text/>" +
|
|
"</element>" +
|
|
"<element name='ImpactAnimationLifetime' a:help='length of the projectile impact animation.'>" +
|
|
"<ref name='positiveDecimal'/>" +
|
|
"</element>" +
|
|
"</optional>" +
|
|
"</interleave>" +
|
|
"</element>" +
|
|
"</optional>" +
|
|
Attack.prototype.preferredClassesSchema +
|
|
Attack.prototype.restrictedClassesSchema +
|
|
"</interleave>" +
|
|
"</element>" +
|
|
"</oneOrMore>";
|
|
|
|
Attack.prototype.Init = function()
|
|
{
|
|
};
|
|
|
|
Attack.prototype.GetAttackTypes = function(wantedTypes)
|
|
{
|
|
const types = g_AttackTypes.filter(type => !!this.template[type]);
|
|
if (!wantedTypes)
|
|
return types;
|
|
|
|
const wantedTypesReal = wantedTypes.filter(wtype => wtype.indexOf("!") != 0);
|
|
return types.filter(type => wantedTypes.indexOf("!" + type) == -1 &&
|
|
(!wantedTypesReal || !wantedTypesReal.length || wantedTypesReal.indexOf(type) != -1));
|
|
};
|
|
|
|
Attack.prototype.GetPreferredClasses = function(type)
|
|
{
|
|
if (this.template[type] && this.template[type].PreferredClasses &&
|
|
this.template[type].PreferredClasses._string)
|
|
return this.template[type].PreferredClasses._string.split(/\s+/);
|
|
|
|
return [];
|
|
};
|
|
|
|
Attack.prototype.GetRestrictedClasses = function(type)
|
|
{
|
|
if (this.template[type] && this.template[type].RestrictedClasses &&
|
|
this.template[type].RestrictedClasses._string)
|
|
return this.template[type].RestrictedClasses._string.split(/\s+/);
|
|
|
|
return [];
|
|
};
|
|
|
|
Attack.prototype.CanAttack = function(target, wantedTypes)
|
|
{
|
|
const cmpFormation = Engine.QueryInterface(target, IID_Formation);
|
|
if (cmpFormation)
|
|
return true;
|
|
|
|
const cmpThisPosition = Engine.QueryInterface(this.entity, IID_Position);
|
|
const cmpTargetPosition = Engine.QueryInterface(target, IID_Position);
|
|
if (!cmpThisPosition || !cmpTargetPosition || !cmpThisPosition.IsInWorld() || !cmpTargetPosition.IsInWorld())
|
|
return false;
|
|
|
|
const cmpResistance = QueryMiragedInterface(target, IID_Resistance);
|
|
if (!cmpResistance)
|
|
return false;
|
|
|
|
const cmpIdentity = QueryMiragedInterface(target, IID_Identity);
|
|
if (!cmpIdentity)
|
|
return false;
|
|
|
|
const cmpHealth = QueryMiragedInterface(target, IID_Health);
|
|
const targetClasses = cmpIdentity.GetClassesList();
|
|
if (targetClasses.indexOf("Domestic") != -1 && this.template.Slaughter && cmpHealth && cmpHealth.GetHitpoints() &&
|
|
(!wantedTypes || !wantedTypes.filter(wType => wType.indexOf("!") != 0).length || wantedTypes.indexOf("Slaughter") != -1))
|
|
return true;
|
|
|
|
const cmpEntityPlayer = QueryOwnerInterface(this.entity);
|
|
const cmpTargetPlayer = QueryOwnerInterface(target);
|
|
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);
|
|
|
|
for (const type of types)
|
|
{
|
|
if (type != "Capture" && (!cmpDiplomacy?.IsEnemy(targetOwner) || !cmpHealth || !cmpHealth.GetHitpoints()))
|
|
continue;
|
|
|
|
if (type == "Capture" && (!cmpCapturable || !cmpCapturable.CanCapture(entityOwner)))
|
|
continue;
|
|
|
|
// 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);
|
|
if (!restrictedClasses.length)
|
|
return true;
|
|
|
|
if (!MatchesClassList(targetClasses, restrictedClasses))
|
|
return true;
|
|
}
|
|
|
|
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.
|
|
*/
|
|
Attack.prototype.GetPreference = function(target)
|
|
{
|
|
const cmpIdentity = Engine.QueryInterface(target, IID_Identity);
|
|
if (!cmpIdentity)
|
|
return undefined;
|
|
|
|
const targetClasses = cmpIdentity.GetClassesList();
|
|
|
|
let minPref;
|
|
for (const type of this.GetAttackTypes())
|
|
{
|
|
const preferredClasses = this.GetPreferredClasses(type);
|
|
for (let pref = 0; pref < preferredClasses.length; ++pref)
|
|
{
|
|
if (MatchesClassList(targetClasses, preferredClasses[pref]))
|
|
{
|
|
if (pref === 0)
|
|
return pref;
|
|
if ((minPref === undefined || minPref > pref))
|
|
minPref = pref;
|
|
}
|
|
}
|
|
}
|
|
return minPref;
|
|
};
|
|
|
|
/**
|
|
* Get the full range of attack using all available attack types.
|
|
*/
|
|
Attack.prototype.GetFullAttackRange = function()
|
|
{
|
|
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;
|
|
};
|
|
|
|
Attack.prototype.GetAttackEffectsData = function(type, splash)
|
|
{
|
|
let template = this.template[type];
|
|
if (!template)
|
|
return undefined;
|
|
if (splash)
|
|
template = template.Splash;
|
|
return AttackHelper.GetAttackEffectsData("Attack/" + type + (splash ? "/Splash" : ""), template, this.entity);
|
|
};
|
|
|
|
/**
|
|
* Find the best attack against a target.
|
|
* @param {number} target - The entity-ID of the target.
|
|
* @param {boolean} allowCapture - Whether capturing is allowed.
|
|
* @return {string} - The preferred attack type.
|
|
*/
|
|
Attack.prototype.GetBestAttackAgainst = function(target, allowCapture)
|
|
{
|
|
const types = this.GetAttackTypes();
|
|
if (Engine.QueryInterface(target, IID_Formation))
|
|
// TODO: Formation against formation needs review
|
|
return g_AttackTypes.find(attack => types.indexOf(attack) != -1);
|
|
|
|
const cmpIdentity = Engine.QueryInterface(target, IID_Identity);
|
|
if (!cmpIdentity)
|
|
return undefined;
|
|
|
|
// Always slaughter domestic animals instead of using a normal attack
|
|
if (this.template.Slaughter && cmpIdentity.HasClass("Domestic"))
|
|
return "Slaughter";
|
|
|
|
const targetClasses = cmpIdentity.GetClassesList();
|
|
const getPreferrence = attackType =>
|
|
{
|
|
let pref = 0;
|
|
if (MatchesClassList(targetClasses, this.GetPreferredClasses(attackType)))
|
|
pref += 2;
|
|
if (allowCapture ? attackType === "Capture" : attackType !== "Capture")
|
|
pref++;
|
|
return pref;
|
|
};
|
|
|
|
return types.filter(type => this.CanAttack(target, [type])).sort((a, b) =>
|
|
{
|
|
const prefA = getPreferrence(a);
|
|
const prefB = getPreferrence(b);
|
|
return (types.indexOf(a) + (prefA > 0 ? prefA + types.length : 0)) -
|
|
(types.indexOf(b) + (prefB > 0 ? prefB + types.length : 0));
|
|
}).pop();
|
|
};
|
|
|
|
Attack.prototype.CompareEntitiesByPreference = function(a, b)
|
|
{
|
|
const aPreference = this.GetPreference(a);
|
|
const bPreference = this.GetPreference(b);
|
|
|
|
if (aPreference === null && bPreference === null) return 0;
|
|
if (aPreference === null) return 1;
|
|
if (bPreference === null) return -1;
|
|
return aPreference - bPreference;
|
|
};
|
|
|
|
Attack.prototype.GetAttackName = function(type)
|
|
{
|
|
return {
|
|
"name": this.template[type].AttackName._string || this.template[type].AttackName,
|
|
"context": this.template[type].AttackName["@context"]
|
|
};
|
|
};
|
|
|
|
Attack.prototype.GetRepeatTime = function(type)
|
|
{
|
|
let repeatTime = 1000;
|
|
|
|
if (this.template[type] && this.template[type].RepeatTime)
|
|
repeatTime = +this.template[type].RepeatTime;
|
|
|
|
return ApplyValueModificationsToEntity("Attack/" + type + "/RepeatTime", repeatTime, this.entity);
|
|
};
|
|
|
|
Attack.prototype.GetTimers = function(type)
|
|
{
|
|
return {
|
|
"prepare": ApplyValueModificationsToEntity("Attack/" + type + "/PrepareTime", +(this.template[type].PrepareTime || 0), this.entity),
|
|
"repeat": this.GetRepeatTime(type)
|
|
};
|
|
};
|
|
|
|
Attack.prototype.GetSplashData = function(type)
|
|
{
|
|
if (!this.template[type].Splash)
|
|
return undefined;
|
|
|
|
return {
|
|
"attackData": this.GetAttackEffectsData(type, true),
|
|
"friendlyFire": this.template[type].Splash.FriendlyFire == "true",
|
|
"radius": ApplyValueModificationsToEntity("Attack/" + type + "/Splash/Range", +this.template[type].Splash.Range, this.entity),
|
|
"shape": this.template[type].Splash.Shape,
|
|
};
|
|
};
|
|
|
|
Attack.prototype.GetRange = function(type)
|
|
{
|
|
if (!type || !this.template[type])
|
|
return this.GetFullAttackRange();
|
|
|
|
let max = +this.template[type].MaxRange;
|
|
max = ApplyValueModificationsToEntity("Attack/" + type + "/MaxRange", max, this.entity);
|
|
|
|
let min = +(this.template[type].MinRange || 0);
|
|
min = ApplyValueModificationsToEntity("Attack/" + type + "/MinRange", min, this.entity);
|
|
|
|
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)
|
|
{
|
|
if (!this.template[type].Origin)
|
|
return 0;
|
|
return ApplyValueModificationsToEntity("Attack/" + type + "/Origin/Y", +this.template[type].Origin.Y, this.entity);
|
|
};
|
|
|
|
Attack.prototype.RepeatRangeCheck = function(type)
|
|
{
|
|
if (!this.IsTargetInRange(this.target, type))
|
|
this.StopAttacking("OutOfRange");
|
|
};
|
|
|
|
/**
|
|
* @param {number} target - The target to attack.
|
|
* @param {string} type - The type of attack to use.
|
|
* @param {number} callerIID - The IID to notify on specific events.
|
|
*
|
|
* @return {boolean} - Whether we started attacking.
|
|
*/
|
|
Attack.prototype.StartAttacking = function(target, type, callerIID, force)
|
|
{
|
|
if (this.target)
|
|
this.StopAttacking();
|
|
|
|
if (!this.CanAttack(target, [type]))
|
|
return false;
|
|
|
|
const cmpResistance = QueryMiragedInterface(target, IID_Resistance);
|
|
if (!cmpResistance || !cmpResistance.AddAttacker(this.entity))
|
|
return false;
|
|
|
|
const timings = this.GetTimers(type);
|
|
const cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer);
|
|
|
|
// If the repeat time since the last attack hasn't elapsed,
|
|
// delay the action to avoid attacking too fast.
|
|
let prepare = timings.prepare;
|
|
if (this.lastAttacked)
|
|
{
|
|
const repeatLeft = this.lastAttacked + timings.repeat - cmpTimer.GetTime();
|
|
prepare = Math.max(prepare, repeatLeft);
|
|
}
|
|
|
|
const cmpVisual = Engine.QueryInterface(this.entity, IID_Visual);
|
|
if (cmpVisual)
|
|
{
|
|
cmpVisual.SelectAnimation("attack_" + type.toLowerCase(), false, 1.0);
|
|
cmpVisual.SetAnimationSyncRepeat(timings.repeat);
|
|
cmpVisual.SetAnimationSyncOffset(prepare);
|
|
}
|
|
|
|
// Find the number of range checks needed during repeat time.
|
|
const numCheck = Math.ceil(timings.repeat / 1000);
|
|
|
|
// Calculate the timing offset to be half the check repeat time.
|
|
const repeatPerCheck = timings.repeat / numCheck;
|
|
const offset = repeatPerCheck / 2;
|
|
|
|
// Set the startpoint to be the prepare time plus any remaining time not evenly divisible by the offset.
|
|
let checkStart = timings.prepare + ((prepare - timings.prepare) % offset);
|
|
|
|
// Add an offset to the start time if we are not already offset.
|
|
if ((prepare - checkStart) % repeatPerCheck === 0)
|
|
checkStart += offset;
|
|
|
|
// If using a non-default prepare time, re-sync the animation when the timer runs.
|
|
this.resyncAnimation = prepare != timings.prepare;
|
|
this.target = target;
|
|
this.callerIID = callerIID;
|
|
this.force = force;
|
|
this.timer = cmpTimer.SetInterval(this.entity, IID_Attack, "Attack", prepare, timings.repeat, type);
|
|
this.checkTimer = cmpTimer.SetInterval(this.entity, IID_Attack, "RepeatRangeCheck", checkStart, repeatPerCheck, type);
|
|
return true;
|
|
};
|
|
|
|
/**
|
|
* @param {string} reason - The reason why we stopped attacking.
|
|
*/
|
|
Attack.prototype.StopAttacking = function(reason)
|
|
{
|
|
if (!this.target)
|
|
return;
|
|
|
|
const cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer);
|
|
cmpTimer.CancelTimer(this.timer);
|
|
cmpTimer.CancelTimer(this.checkTimer);
|
|
delete this.timer;
|
|
delete this.checkTimer;
|
|
|
|
const cmpResistance = QueryMiragedInterface(this.target, IID_Resistance);
|
|
if (cmpResistance)
|
|
cmpResistance.RemoveAttacker(this.entity);
|
|
|
|
delete this.target;
|
|
|
|
const cmpVisual = Engine.QueryInterface(this.entity, IID_Visual);
|
|
if (cmpVisual)
|
|
cmpVisual.SelectAnimation("idle", false, 1.0);
|
|
|
|
// The callerIID component may start again,
|
|
// replacing the callerIID, hence save that.
|
|
const callerIID = this.callerIID;
|
|
delete this.callerIID;
|
|
|
|
if (reason && callerIID)
|
|
{
|
|
const component = Engine.QueryInterface(this.entity, callerIID);
|
|
if (component)
|
|
component.ProcessMessage(reason, null);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Attack our target entity.
|
|
* @param {string} data - The attack type to use.
|
|
* @param {number} lateness - The offset of the actual call and when it was expected.
|
|
*/
|
|
Attack.prototype.Attack = function(type, lateness)
|
|
{
|
|
if (!this.CanAttack(this.target, [type]))
|
|
{
|
|
this.StopAttacking("TargetInvalidated");
|
|
return;
|
|
}
|
|
|
|
// ToDo: Enable entities to keep facing a target.
|
|
Engine.QueryInterface(this.entity, IID_UnitAI)?.FaceTowardsTarget(this.target);
|
|
|
|
const cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer);
|
|
this.lastAttacked = cmpTimer.GetTime() - lateness;
|
|
|
|
// BuildingAI has its own attack routine.
|
|
if (!Engine.QueryInterface(this.entity, IID_BuildingAI))
|
|
this.PerformAttack(type, this.target);
|
|
|
|
if (!this.target)
|
|
return;
|
|
|
|
// We check the range after the attack to facilitate chasing.
|
|
if (!this.IsTargetInRange(this.target, type))
|
|
{
|
|
this.StopAttacking("OutOfRange");
|
|
return;
|
|
}
|
|
|
|
// If a low preference unit is attacked without player direction, check for higher preference.
|
|
if (this.GetPreference(this.target) === undefined && !this.force)
|
|
{
|
|
this.StopAttacking("TargetInvalidated");
|
|
return;
|
|
}
|
|
|
|
if (this.resyncAnimation)
|
|
{
|
|
const cmpVisual = Engine.QueryInterface(this.entity, IID_Visual);
|
|
if (cmpVisual)
|
|
{
|
|
const repeat = this.GetTimers(type).repeat;
|
|
cmpVisual.SetAnimationSyncRepeat(repeat);
|
|
cmpVisual.SetAnimationSyncOffset(repeat);
|
|
}
|
|
delete this.resyncAnimation;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Attack the target entity. This should only be called after a successful range check,
|
|
* and should only be called after GetTimers().repeat msec has passed since the last
|
|
* call to PerformAttack.
|
|
*/
|
|
Attack.prototype.PerformAttack = function(type, target)
|
|
{
|
|
const cmpPosition = Engine.QueryInterface(this.entity, IID_Position);
|
|
if (!cmpPosition || !cmpPosition.IsInWorld())
|
|
return;
|
|
const selfPosition = cmpPosition.GetPosition();
|
|
|
|
const cmpTargetPosition = Engine.QueryInterface(target, IID_Position);
|
|
if (!cmpTargetPosition || !cmpTargetPosition.IsInWorld())
|
|
return;
|
|
const targetPosition = cmpTargetPosition.GetPosition();
|
|
|
|
const cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership);
|
|
if (!cmpOwnership)
|
|
return;
|
|
const attackerOwner = cmpOwnership.GetOwner();
|
|
|
|
const data = {
|
|
"type": type,
|
|
"attackData": this.GetAttackEffectsData(type),
|
|
"splash": this.GetSplashData(type),
|
|
"attacker": this.entity,
|
|
"attackerOwner": attackerOwner,
|
|
"target": target,
|
|
};
|
|
|
|
let delay = +(this.template[type].EffectDelay || 0);
|
|
|
|
if (this.template[type].Projectile)
|
|
{
|
|
const cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer);
|
|
const turnLength = cmpTimer.GetLatestTurnLength()/1000;
|
|
// In the future this could be extended:
|
|
// * Obstacles like trees could reduce the probability of the target being hit
|
|
// * Obstacles like walls should block projectiles entirely
|
|
|
|
const horizSpeed = +this.template[type].Projectile.Speed;
|
|
const gravity = +this.template[type].Projectile.Gravity;
|
|
// horizSpeed /= 2; gravity /= 2; // slow it down for testing
|
|
|
|
// We will try to estimate the position of the target, where we can hit it.
|
|
// We first estimate the time-till-hit by extrapolating linearly the movement
|
|
// of the last turn. We compute the time till an arrow will intersect the target.
|
|
const targetVelocity = Vector3D.sub(targetPosition, cmpTargetPosition.GetPreviousPosition()).div(turnLength);
|
|
|
|
let timeToTarget = PositionHelper.PredictTimeToTarget(selfPosition, horizSpeed, targetPosition, targetVelocity);
|
|
|
|
// 'Cheat' and use UnitMotion to predict the position in the near-future.
|
|
// This avoids 'dancing' issues with units zigzagging over very short distances.
|
|
// However, this could fail if the player gives several short move orders, so
|
|
// occasionally fall back to basic interpolation.
|
|
let predictedPosition = targetPosition;
|
|
if (timeToTarget !== false)
|
|
{
|
|
// Don't predict too far in the future, but avoid threshold effects.
|
|
// After 1 second, always use the 'dumb' interpolated past-motion prediction.
|
|
const useUnitMotion = randBool(Math.max(0, 0.75 - timeToTarget / 1.333));
|
|
if (useUnitMotion)
|
|
{
|
|
const cmpTargetUnitMotion = Engine.QueryInterface(target, IID_UnitMotion);
|
|
const cmpTargetUnitAI = Engine.QueryInterface(target, IID_UnitAI);
|
|
if (cmpTargetUnitMotion && (!cmpTargetUnitAI || !cmpTargetUnitAI.IsFormationMember()))
|
|
{
|
|
const pos2D = cmpTargetUnitMotion.EstimateFuturePosition(timeToTarget);
|
|
predictedPosition.x = pos2D.x;
|
|
predictedPosition.z = pos2D.y;
|
|
}
|
|
else
|
|
predictedPosition = Vector3D.mult(targetVelocity, timeToTarget).add(targetPosition);
|
|
}
|
|
else
|
|
predictedPosition = Vector3D.mult(targetVelocity, timeToTarget).add(targetPosition);
|
|
}
|
|
|
|
const predictedHeight = cmpTargetPosition.GetHeightAt(predictedPosition.x, predictedPosition.z);
|
|
|
|
// Add inaccuracy based on spread.
|
|
const distanceModifiedSpread = ApplyValueModificationsToEntity("Attack/" + type + "/Projectile/Spread", +this.template[type].Projectile.Spread, this.entity) *
|
|
predictedPosition.horizDistanceTo(selfPosition) / 100;
|
|
|
|
const randNorm = randomNormal2D();
|
|
const offsetX = randNorm[0] * distanceModifiedSpread;
|
|
const offsetZ = randNorm[1] * distanceModifiedSpread;
|
|
|
|
data.position = new Vector3D(predictedPosition.x + offsetX, predictedHeight, predictedPosition.z + offsetZ);
|
|
|
|
const realHorizDistance = data.position.horizDistanceTo(selfPosition);
|
|
timeToTarget = realHorizDistance / horizSpeed;
|
|
delay += timeToTarget * 1000;
|
|
|
|
data.direction = Vector3D.sub(data.position, selfPosition).div(realHorizDistance);
|
|
|
|
let actorName = this.template[type].Projectile.ActorName || "";
|
|
const impactActorName = this.template[type].Projectile.ImpactActorName || "";
|
|
const impactAnimationLifetime = this.template[type].Projectile.ImpactAnimationLifetime || 0;
|
|
|
|
// TODO: Use unit rotation to implement x/z offsets.
|
|
const deltaLaunchPoint = new Vector3D(0, +this.template[type].Projectile.LaunchPoint["@y"], 0);
|
|
let launchPoint = Vector3D.add(selfPosition, deltaLaunchPoint);
|
|
|
|
const cmpVisual = Engine.QueryInterface(this.entity, IID_Visual);
|
|
if (cmpVisual)
|
|
{
|
|
// if the projectile definition is missing from the template
|
|
// then fallback to the projectile name and launchpoint in the visual actor
|
|
if (!actorName)
|
|
actorName = cmpVisual.GetProjectileActor();
|
|
|
|
const visualActorLaunchPoint = cmpVisual.GetProjectileLaunchPoint();
|
|
if (visualActorLaunchPoint.length() > 0)
|
|
launchPoint = visualActorLaunchPoint;
|
|
}
|
|
|
|
const cmpProjectileManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ProjectileManager);
|
|
data.projectileId = cmpProjectileManager.LaunchProjectileAtPoint(launchPoint, data.position, horizSpeed, gravity, actorName, impactActorName, impactAnimationLifetime);
|
|
|
|
const cmpSound = Engine.QueryInterface(this.entity, IID_Sound);
|
|
data.attackImpactSound = cmpSound ? cmpSound.GetSoundGroup("attack_impact_" + type.toLowerCase()) : "";
|
|
|
|
data.friendlyFire = this.template[type].Projectile.FriendlyFire == "true";
|
|
}
|
|
else
|
|
{
|
|
data.position = targetPosition;
|
|
data.direction = Vector3D.sub(targetPosition, selfPosition);
|
|
}
|
|
if (delay)
|
|
{
|
|
const cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer);
|
|
cmpTimer.SetTimeout(SYSTEM_ENTITY, IID_DelayedDamage, "Hit", delay, data);
|
|
}
|
|
else
|
|
Engine.QueryInterface(SYSTEM_ENTITY, IID_DelayedDamage).Hit(data, 0);
|
|
};
|
|
|
|
/**
|
|
* @param {number} - The entity ID of the target to check.
|
|
* @return {boolean} - Whether this entity is in range of its target.
|
|
*/
|
|
Attack.prototype.IsTargetInRange = function(target, type)
|
|
{
|
|
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)
|
|
{
|
|
if (msg.component != "Attack")
|
|
return;
|
|
|
|
const cmpUnitAI = Engine.QueryInterface(this.entity, IID_UnitAI);
|
|
if (!cmpUnitAI)
|
|
return;
|
|
|
|
if (this.GetAttackTypes().some(type =>
|
|
msg.valueNames.indexOf("Attack/" + type + "/MaxRange") != -1))
|
|
cmpUnitAI.UpdateRangeQueries();
|
|
};
|
|
|
|
Attack.prototype.GetRangeOverlays = function(type = "Ranged")
|
|
{
|
|
if (!this.template[type] || !this.template[type].RangeOverlay)
|
|
return [];
|
|
|
|
const range = this.GetRange(type);
|
|
const rangeOverlays = [];
|
|
for (const i in range)
|
|
if ((i == "min" || i == "max") && range[i])
|
|
rangeOverlays.push({
|
|
"radius": range[i],
|
|
"texture": this.template[type].RangeOverlay.LineTexture,
|
|
"textureMask": this.template[type].RangeOverlay.LineTextureMask,
|
|
"thickness": +this.template[type].RangeOverlay.LineThickness,
|
|
});
|
|
return rangeOverlays;
|
|
};
|
|
|
|
Engine.RegisterComponentType(IID_Attack, "Attack", Attack);
|