Index: binaries/data/mods/public/gui/session/input.js
===================================================================
--- binaries/data/mods/public/gui/session/input.js
+++ binaries/data/mods/public/gui/session/input.js
@@ -17,6 +17,7 @@
const ACTION_REPAIR = 2;
const ACTION_GUARD = 3;
const ACTION_PATROL = 4;
+const ACTION_ATTACKGROUND = 5;
var preSelectedAction = ACTION_NONE;
const INPUT_NORMAL = 0;
Index: binaries/data/mods/public/gui/session/unit_actions.js
===================================================================
--- binaries/data/mods/public/gui/session/unit_actions.js
+++ binaries/data/mods/public/gui/session/unit_actions.js
@@ -238,6 +238,44 @@
"specificness": 10,
},
+ "attack-ground": {
+ "execute": function(target, action, selection, queued)
+ {
+ Engine.PostNetworkCommand({
+ "type": "attack-ground",
+ "entities": selection,
+ "target": target,
+ "queued": queued
+ });
+
+ Engine.GuiInterfaceCall("PlaySound", {
+ "name": "order_attack",
+ "entity": selection[0]
+ });
+ DrawTargetMarker(target);
+
+ return true;
+ },
+ "getActionInfo": function(selection, target)
+ {
+ return {
+ "possible": true
+ };
+ },
+ "preSelectedActionCheck": function(target, selection)
+ {
+ if (preSelectedAction != ACTION_ATTACKGROUND)
+ return false;
+
+ return {
+ "type": "attack-ground",
+ "cursor": "action-attack",
+ "target": target
+ };
+ },
+ "specificness": 50,
+ },
+
"patrol":
{
"execute": function(target, action, selection, queued)
@@ -1365,6 +1403,26 @@
},
},
+ "attack-ground": {
+ "getInfo": function(entStates)
+ {
+ if (entStates.every(entState => !entState.attack ||
+ Object.keys(entState.attack).every(type => !entState.attack[type].attackGround)))
+ return false;
+
+ return {
+ "tooltip": colorizeHotkey("%(hotkey)s" + " ", "session.attackground") +
+ translate("Attack the selected ground."),
+ "icon": "attack_ground.png"
+ };
+ },
+ "execute": function(entStates)
+ {
+ inputState = INPUT_PRESELECTEDACTION;
+ preSelectedAction = ACTION_ATTACKGROUND;
+ },
+ },
+
"patrol": {
"getInfo": function(entStates)
{
@@ -1617,7 +1675,7 @@
}
return {
- "possible": ["move", "attack-move", "remove-guard", "patrol"].indexOf(action) != -1
+ "possible": ["move", "attack-move", "remove-guard", "patrol", "attack-ground"].indexOf(action) != -1
};
}
Index: binaries/data/mods/public/simulation/components/Attack.js
===================================================================
--- binaries/data/mods/public/simulation/components/Attack.js
+++ binaries/data/mods/public/simulation/components/Attack.js
@@ -84,6 +84,7 @@
"0.0" +
"" +
"" +
+ "" +
"" +
"" +
"" +
@@ -177,6 +178,9 @@
"" +
"" +
"" +
+ ""+
+ "" +
+ "" +
Attack.prototype.preferredClassesSchema +
Attack.prototype.restrictedClassesSchema +
"" +
@@ -241,8 +245,25 @@
return [];
};
+/**
+ * Whether the entity is able to attack ground (with the requested attack type).
+ *
+ * @param {string | undefined} attackType - The attack type requested.
+ * @return {boolean} Whether the entity is able to attack ground.
+ */
+Attack.prototype.CanAttackGround = function(attackType)
+{
+ if (attackType)
+ return "AttackGround" in this.template[attackType];
+
+ return this.GetAttackTypes().some(type => "AttackGround" in this.template[type]);
+};
+
Attack.prototype.CanAttack = function(target, wantedTypes)
{
+ if (target instanceof Vector3D)
+ return this.CanAttackGround();
+
let cmpFormation = Engine.QueryInterface(target, IID_Formation);
if (cmpFormation)
return true;
@@ -357,6 +378,11 @@
*/
Attack.prototype.GetBestAttackAgainst = function(target, allowCapture)
{
+ // ToDo: Add support for more attack types based on some sort of preference
+ // (more splash is more better, DPS, range or something else?).
+ if (target instanceof Vector3D)
+ return this.GetAttackTypes().find(type => this.CanAttackGround(type));
+
let cmpFormation = Engine.QueryInterface(target, IID_Formation);
if (cmpFormation)
{
@@ -445,9 +471,12 @@
};
/**
- * Attack the target entity. This should only be called after a successful range check,
+ * Attack the target. 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.
+ *
+ * @param {string} type - The type of the attack (e.g. "Melee", "Ranged", "Capture", "Slaughter").
+ * @param {number | Vector3D} target - Either the target entity ID, or a Vector3D of a position to attack.
*/
Attack.prototype.PerformAttack = function(type, target)
{
@@ -470,16 +499,23 @@
if (!cmpPosition || !cmpPosition.IsInWorld())
return;
let selfPosition = cmpPosition.GetPosition();
- let cmpTargetPosition = Engine.QueryInterface(target, IID_Position);
- if (!cmpTargetPosition || !cmpTargetPosition.IsInWorld())
- return;
- let targetPosition = cmpTargetPosition.GetPosition();
- let previousTargetPosition = Engine.QueryInterface(target, IID_Position).GetPreviousPosition();
- let targetVelocity = Vector3D.sub(targetPosition, previousTargetPosition).div(turnLength);
+ let predictedPosition;
+ if (typeof target == "number")
+ {
+ let cmpTargetPosition = Engine.QueryInterface(target, IID_Position);
+ if (!cmpTargetPosition || !cmpTargetPosition.IsInWorld())
+ return;
+ let targetPosition = cmpTargetPosition.GetPosition();
+
+ let previousTargetPosition = Engine.QueryInterface(target, IID_Position).GetPreviousPosition();
+ let targetVelocity = Vector3D.sub(targetPosition, previousTargetPosition).div(turnLength);
- let timeToTarget = this.PredictTimeToTarget(selfPosition, horizSpeed, targetPosition, targetVelocity);
- let predictedPosition = (timeToTarget !== false) ? Vector3D.mult(targetVelocity, timeToTarget).add(targetPosition) : targetPosition;
+ let timeToTarget = this.PredictTimeToTarget(selfPosition, horizSpeed, targetPosition, targetVelocity);
+ predictedPosition = (timeToTarget !== false) ? Vector3D.mult(targetVelocity, timeToTarget).add(targetPosition) : targetPosition;
+ }
+ else
+ predictedPosition = target;
// Add inaccuracy based on spread.
let distanceModifiedSpread = ApplyValueModificationsToEntity("Attack/Ranged/Spread", +this.template[type].Projectile.Spread, this.entity) *
@@ -489,11 +525,11 @@
let offsetX = randNorm[0] * distanceModifiedSpread;
let offsetZ = randNorm[1] * distanceModifiedSpread;
- let realTargetPosition = new Vector3D(predictedPosition.x + offsetX, targetPosition.y, predictedPosition.z + offsetZ);
+ let realTargetPosition = new Vector3D(predictedPosition.x + offsetX, predictedPosition.y, predictedPosition.z + offsetZ);
// Recalculate when the missile will hit the target position.
let realHorizDistance = realTargetPosition.horizDistanceTo(selfPosition);
- timeToTarget = realHorizDistance / horizSpeed;
+ let timeToTarget = realHorizDistance / horizSpeed;
let missileDirection = Vector3D.sub(realTargetPosition, selfPosition).div(realHorizDistance);
Index: binaries/data/mods/public/simulation/components/DelayedDamage.js
===================================================================
--- binaries/data/mods/public/simulation/components/DelayedDamage.js
+++ binaries/data/mods/public/simulation/components/DelayedDamage.js
@@ -12,7 +12,7 @@
* @param {Object} data - The data sent by the caller.
* @param {string} data.type - The type of damage.
* @param {Object} data.attackData - Data of the form { 'effectType': { ...opaque effect data... }, 'Bonuses': {...} }.
- * @param {number} data.target - The entity id of the target.
+ * @param {number | Vector3D} data.target - Either the target entity ID, or a Vector3D of a position to attack.
* @param {number} data.attacker - The entity id of the attacker.
* @param {number} data.attackerOwner - The player id of the owner of the attacker.
* @param {Vector2D} data.origin - The origin of the projectile hit.
@@ -54,21 +54,27 @@
let cmpProjectileManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ProjectileManager);
- // Deal direct damage if we hit the main target
- // and if the target has Resistance (not the case for a mirage for example)
- if (Attacking.TestCollision(data.target, data.position, lateness))
+ let targetPosition;
+ if (typeof data.target == "number")
{
- cmpProjectileManager.RemoveProjectile(data.projectileId);
-
- Attacking.HandleAttackEffects(data.type, data.attackData, data.target, data.attacker, data.attackerOwner);
- return;
+ // Deal direct damage if we hit the main target
+ // and if the target has Resistance (not the case for a mirage for example).
+ if (Attacking.TestCollision(data.target, data.position, lateness))
+ {
+ cmpProjectileManager.RemoveProjectile(data.projectileId);
+
+ Attacking.HandleAttackEffects(data.type, data.attackData, data.target, data.attacker, data.attackerOwner);
+ return;
+ }
+ targetPosition = Attacking.InterpolatedLocation(data.target, lateness);
}
+ else
+ targetPosition = data.target;
- let targetPosition = Attacking.InterpolatedLocation(data.target, lateness);
if (!targetPosition)
return;
- // If we didn't hit the main target look for nearby units.
+ // Look for nearby units.
let ents = Attacking.EntitiesNearPoint(Vector2D.from3D(data.position),
targetPosition.horizDistanceTo(data.position) * 2,
Attacking.GetPlayersToDamage(data.attackerOwner, data.friendlyFire));
Index: binaries/data/mods/public/simulation/components/FormationAttack.js
===================================================================
--- binaries/data/mods/public/simulation/components/FormationAttack.js
+++ binaries/data/mods/public/simulation/components/FormationAttack.js
@@ -21,46 +21,63 @@
return [];
};
+/**
+ * Queries the individual attack ranges of members to get the "range" of this formation.
+ * @param {number} target - The target to attack.
+ * @return {Object} - The range of the formation in the form:
+ * "min": {number} - The minimum range to use.
+ * "max": {number} - The maximum range to use.
+ * "elevationBonus": {number} - The elevationBonus to use.
+ */
FormationAttack.prototype.GetRange = function(target)
{
- var result = {"min": 0, "max": this.canAttackAsFormation ? -1 : 0};
- var cmpFormation = Engine.QueryInterface(this.entity, IID_Formation);
+ let result = {
+ "min": 0,
+ "max": this.canAttackAsFormation ? -1 : 0,
+ "elevationBonus": this.canAttackAsFormation ? -1 : 0
+ };
+ let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation);
if (!cmpFormation)
{
warn("FormationAttack component used on a non-formation entity");
return result;
}
- var members = cmpFormation.GetMembers();
- for (var ent of members)
+ let members = cmpFormation.GetMembers();
+ for (let ent of members)
{
- var cmpAttack = Engine.QueryInterface(ent, IID_Attack);
+ let cmpAttack = Engine.QueryInterface(ent, IID_Attack);
if (!cmpAttack)
continue;
- var type = cmpAttack.GetBestAttackAgainst(target);
+ let type = cmpAttack.GetBestAttackAgainst(target);
if (!type)
continue;
// if the formation can attack, take the minimum max range (so units are certainly in range),
// If the formation can't attack, take the maximum max range as the point where the formation will be disbanded
// Always take the minimum min range (to not get impossible situations)
- var range = cmpAttack.GetRange(type);
-
+ let range = cmpAttack.GetRange(type);
if (this.canAttackAsFormation)
{
if (range.max < result.max || result.max < 0)
result.max = range.max;
+
+ if (range.elevationBonus < result.elevationBonus || result.max < 0)
+ result.elevationBonus = range.elevationBonus;
}
else
{
if (range.max > result.max || range.max < 0)
result.max = range.max;
+
+ if (range.elevationBonus > result.elevationBonus)
+ result.elevationBonus = range.elevationBonus;
}
if (range.min < result.min)
result.min = range.min;
}
// add half the formation size, so it counts as the range for the units on the first row
- var extraRange = cmpFormation.GetSize().depth/2;
+ let extraRange = cmpFormation.GetSize().depth / 2;
if (result.max >= 0)
result.max += extraRange;
Index: binaries/data/mods/public/simulation/components/GuiInterface.js
===================================================================
--- binaries/data/mods/public/simulation/components/GuiInterface.js
+++ binaries/data/mods/public/simulation/components/GuiInterface.js
@@ -457,6 +457,8 @@
// not in world, set a default?
ret.attack[type].elevationAdaptedRange = ret.attack.maxRange;
}
+
+ ret.attack[type].attackGround = cmpAttack.CanAttackGround(type);
}
}
Index: binaries/data/mods/public/simulation/components/UnitAI.js
===================================================================
--- binaries/data/mods/public/simulation/components/UnitAI.js
+++ binaries/data/mods/public/simulation/components/UnitAI.js
@@ -438,7 +438,7 @@
if (this.IsAnimal())
this.SetNextState("ANIMAL.COMBAT.ATTACKING");
else
- this.SetNextState("INDIVIDUAL.COMBAT.ATTACKING");
+ this.SetNextState("INDIVIDUAL.COMBAT");
return;
}
@@ -469,6 +469,18 @@
this.SetNextState("INDIVIDUAL.COMBAT.APPROACHING");
},
+ "Order.AttackGround": function(msg) {
+ let type = this.GetBestAttackAgainst(this.order.data.target);
+ if (!type)
+ {
+ this.FinishOrder();
+ return;
+ }
+ this.order.data.attackType = type;
+
+ this.SetNextState("INDIVIDUAL.COMBAT");
+ },
+
"Order.Patrol": function(msg) {
if (this.IsAnimal() || this.IsTurret())
{
@@ -774,6 +786,24 @@
this.SetNextState("MEMBER");
},
+ "Order.AttackGround": function(msg) {
+ let target = msg.data.target;
+
+ // Check if we are already in range, otherwise walk there
+ if (!this.CheckFormationTargetAttackRange(target))
+ {
+ this.SetNextState("COMBAT.APPROACHING");
+ return;
+ }
+ this.CallMemberFunction("AttackGround", [target, false]);
+
+ let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack);
+ if (cmpAttack && cmpAttack.CanAttackAsFormation())
+ this.SetNextState("COMBAT.ATTACKING");
+ else
+ this.SetNextState("MEMBER");
+ },
+
"Order.Garrison": function(msg) {
if (!Engine.QueryInterface(msg.data.target, IID_GarrisonHolder))
{
@@ -1159,8 +1189,12 @@
},
"MovementUpdate": function(msg) {
+ let target = this.order.data.target;
let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack);
- this.CallMemberFunction("Attack", [this.order.data.target, this.order.data.allowCapture, false]);
+ if (this.TargetIsEntity(target))
+ this.CallMemberFunction("Attack", [target, this.order.data.allowCapture, false]);
+ else
+ this.CallMemberFunction("AttackGround", [target, false]);
if (cmpAttack.CanAttackAsFormation())
this.SetNextState("COMBAT.ATTACKING");
else
@@ -1173,13 +1207,25 @@
"enter": function(msg) {
let target = this.order.data.target;
let allowCapture = this.order.data.allowCapture;
+ let attackingEntity = this.TargetIsEntity(target);
// Check if we are already in range, otherwise walk there
if (!this.CheckFormationTargetAttackRange(target))
{
- if (this.TargetIsAlive(target) && this.CheckTargetVisible(target))
+ if (!attackingEntity ||
+ this.TargetIsAlive(target) && this.CheckTargetVisible(target))
{
this.FinishOrder();
- this.PushOrderFront("Attack", { "target": target, "force": false, "allowCapture": allowCapture });
+ if (attackEntity)
+ this.PushOrderFront("Attack", {
+ "target": target,
+ "force": false,
+ "allowCapture": allowCapture
+ });
+ else
+ this.PushOrderFront("AttackGround", {
+ "target": target,
+ "force": false
+ });
return true;
}
this.FinishOrder();
@@ -1197,13 +1243,25 @@
"Timer": function(msg) {
let target = this.order.data.target;
let allowCapture = this.order.data.allowCapture;
+ let attackingEntity = this.TargetIsEntity(target);
// Check if we are already in range, otherwise walk there
if (!this.CheckFormationTargetAttackRange(target))
{
- if (this.TargetIsAlive(target) && this.CheckTargetVisible(target))
+ if (!attackingEntity ||
+ this.TargetIsAlive(target) && this.CheckTargetVisible(target))
{
this.FinishOrder();
- this.PushOrderFront("Attack", { "target": target, "force": false, "allowCapture": allowCapture });
+ if (attackEntity)
+ this.PushOrderFront("Attack", {
+ "target": target,
+ "force": false,
+ "allowCapture": allowCapture
+ });
+ else
+ this.PushOrderFront("AttackGround", {
+ "target": target,
+ "force": false
+ });
return;
}
this.FinishOrder();
@@ -1213,7 +1271,7 @@
"leave": function(msg) {
this.StopTimer();
- var cmpFormation = Engine.QueryInterface(this.entity, IID_Formation);
+ let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation);
if (cmpFormation)
cmpFormation.SetRearrange(true);
},
@@ -1812,6 +1870,43 @@
},
"COMBAT": {
+ "enter": function() {
+ let cmpBuildingAI = Engine.QueryInterface(this.entity, IID_BuildingAI);
+ if (cmpBuildingAI && !this.TargetIsEntity(this.order.data.target))
+ {
+ warn("[UnitAI]: Buildings cannot attack ground yet.");
+ this.FinishOrder();
+ return true;
+ }
+ // If we are already at the target, try attacking it from here.
+ if (this.CheckTargetAttackRange(this.order.data.target, this.order.data.attackType))
+ {
+ // For packable units within attack range:
+ // 1. If unpacked, we can attack the target.
+ // 2. If packed, we first need to unpack, then follow case 1.
+ if (this.CanUnpack())
+ {
+ this.PushOrderFront("Unpack", { "force": true });
+ return true;
+ }
+
+ this.SetNextState("ATTACKING");
+ return true;
+ }
+
+ // For packable units out of attack range:
+ // 1. If packed, we need to move to attack range and then unpack.
+ // 2. If unpacked, we first need to pack, then follow case 1.
+ if (this.CanPack())
+ {
+ this.PushOrderFront("Pack", { "force": true });
+ return true;
+ }
+
+ this.SetNextState("APPROACHING");
+ return false;
+ },
+
"Order.LeaveFoundation": function(msg) {
// Ignore the order as we're busy.
return { "discardOrder": true };
@@ -1840,7 +1935,10 @@
if (!this.formationAnimationVariant)
this.SetAnimationVariant("combat");
- this.StartTimer(1000, 1000);
+ // If attack ground is asked do not start the timer (ground does not run away).
+ if (this.TargetIsEntity(this.order.data.target))
+ this.StartTimer(1000, 1000);
+
return false;
},
@@ -1909,13 +2007,17 @@
"ATTACKING": {
"enter": function() {
let target = this.order.data.target;
- let cmpFormation = Engine.QueryInterface(target, IID_Formation);
- // if the target is a formation, save the attacking formation, and pick a member
- if (cmpFormation)
+ let attackingEntity = this.TargetIsEntity(target);
+ if (attackingEntity)
{
- this.order.data.formationTarget = target;
- target = cmpFormation.GetClosestMember(this.entity);
- this.order.data.target = target;
+ let cmpFormation = Engine.QueryInterface(target, IID_Formation);
+ // If the target is a formation, save the attacking formation, and pick a member
+ if (cmpFormation)
+ {
+ this.order.data.formationTarget = target;
+ target = cmpFormation.GetClosestMember(this.entity);
+ this.order.data.target = target;
+ }
}
if (!this.CanAttack(target))
@@ -4514,6 +4616,15 @@
return cmpUnitMotion && cmpUnitMotion.MoveToTargetRange(target, 0, 1);
};
+/**
+ * Move the entity so we hope the target is in range.
+ *
+ * @param {number | Vector3D} target - Either the target entity ID, or a Vector3D of a position to attack.
+ * @param {number} iid - The interface ID to check the range for.
+ * @param {string} type - The type for which the range is to be checked.
+ *
+ * @return {boolean} - Whether the order to move has succeeded.
+ */
UnitAI.prototype.MoveToTargetRange = function(target, iid, type)
{
if (!this.CheckTargetVisible(target) || this.IsTurret())
@@ -4524,17 +4635,27 @@
return false;
let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion);
- return cmpUnitMotion && cmpUnitMotion.MoveToTargetRange(target, range.min, range.max);
+ if (!cmpUnitMotion)
+ return false;
+
+ if (this.TargetIsEntity(target))
+ return cmpUnitMotion.MoveToTargetRange(target, range.min, range.max);
+ return cmpUnitMotion.MoveToPointRange(target.x, target.z, range.min, range.max);
};
/**
- * Move unit so we hope the target is in the attack range
- * for melee attacks, this goes straight to the default range checks
- * for ranged attacks, the parabolic range is used
+ * Move unit so we hope the target is in the attack range.
+ * For melee attacks, this goes straight to the default range checks.
+ * For ranged attacks, the parabolic range is used.
+ *
+ * @param {number | Vector3D} target - Either the target entity ID, or a Vector3D of a position to attack.
+ * @param {string} type - The type of the attack which is to be used.
+ *
+ * @return {boolean} - Whether the order to move has succeeded.
*/
UnitAI.prototype.MoveToTargetAttackRange = function(target, type)
{
- // for formation members, the formation will take care of the range check
+ // For formation members, the formation will take care of the range check.
if (this.IsFormationMember())
{
let cmpFormationUnitAI = Engine.QueryInterface(this.formationController, IID_UnitAI);
@@ -4542,9 +4663,13 @@
return false;
}
- let cmpFormation = Engine.QueryInterface(target, IID_Formation);
- if (cmpFormation)
- target = cmpFormation.GetClosestMember(this.entity);
+ let attackingEntity = this.TargetIsEntity(target);
+ if (attackingEntity)
+ {
+ let cmpFormation = Engine.QueryInterface(target, IID_Formation);
+ if (cmpFormation)
+ target = cmpFormation.GetClosestMember(this.entity);
+ }
if (type != "Ranged")
return this.MoveToTargetRange(target, IID_Attack, type);
@@ -4556,22 +4681,23 @@
if (!range)
return false;
- let thisCmpPosition = Engine.QueryInterface(this.entity, IID_Position);
- if (!thisCmpPosition.IsInWorld())
+ let cmpPositionSelf = Engine.QueryInterface(this.entity, IID_Position);
+ if (!cmpPositionSelf.IsInWorld())
return false;
- let s = thisCmpPosition.GetPosition();
+ let selfPosition = cmpPositionSelf.GetPosition();
- let targetCmpPosition = Engine.QueryInterface(target, IID_Position);
- if (!targetCmpPosition.IsInWorld())
+ let targetPosition = this.GetTargetPosition3D(target);
+ if (!targetPosition)
return false;
- let t = targetCmpPosition.GetPosition();
- // h is positive when I'm higher than the target
- let h = s.y - t.y + range.elevationBonus;
+ // h Is positive when I'm higher than the target.
+ let h = selfPosition.y - targetPosition.y + range.elevationBonus;
- let parabolicMaxRange = Math.sqrt(Math.square(range.max) + 2 * range.max * h);
- // No negative roots please
- if (h <= -range.max / 2)
+ let parabolicMaxRange;
+ // No negative roots please.
+ if (h > -range.max / 2)
+ parabolicMaxRange = Math.sqrt(Math.square(range.max) + 2 * range.max * h);
+ else
// return false? Or hope you come close enough?
parabolicMaxRange = 0;
@@ -4579,7 +4705,12 @@
let guessedMaxRange = parabolicMaxRange > range.max ? (range.max + parabolicMaxRange) / 2 : parabolicMaxRange;
let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion);
- return cmpUnitMotion && cmpUnitMotion.MoveToTargetRange(target, range.min, guessedMaxRange);
+ if (!cmpUnitMotion)
+ return false;
+
+ if (attackingEntity)
+ return cmpUnitMotion.MoveToTargetRange(target, range.min, guessedMaxRange);
+ return cmpUnitMotion.MoveToPointRange(target.x, target.z, range.min, guessedMaxRange);
};
UnitAI.prototype.MoveToTargetRangeExplicit = function(target, min, max)
@@ -4594,14 +4725,18 @@
/**
* Move unit so we hope the target is in the attack range of the formation.
*
- * @param {number} target - The target entity ID to attack.
+ * @param {number | Vector3D} target - The target entity ID to attack, or a Vector3D of a position to attack.
* @return {boolean} - Whether the order to move has succeeded.
*/
UnitAI.prototype.MoveFormationToTargetAttackRange = function(target)
{
- let cmpTargetFormation = Engine.QueryInterface(target, IID_Formation);
- if (cmpTargetFormation)
- target = cmpTargetFormation.GetClosestMember(this.entity);
+ let attackingEntity = this.TargetIsEntity(target);
+ if (attackingEntity)
+ {
+ let cmpTargetFormation = Engine.QueryInterface(target, IID_Formation);
+ if (cmpTargetFormation)
+ target = cmpTargetFormation.GetClosestMember(this.entity);
+ }
if (!this.CheckTargetVisible(target) || this.IsTurret())
return false;
@@ -4612,7 +4747,12 @@
let range = cmpFormationAttack.GetRange(target);
let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion);
- return cmpUnitMotion && cmpUnitMotion.MoveToTargetRange(target, range.min, range.max);
+ if (!cmpUnitMotion)
+ return false;
+
+ if (attackingEntity)
+ return cmpUnitMotion.MoveToTargetRange(target, range.min, range.max);
+ return cmpUnitMotion.MoveToPointRange(target.x, target.z, range.min, range.max);
};
UnitAI.prototype.MoveToGarrisonRange = function(target)
@@ -4657,6 +4797,15 @@
return cmpObstructionManager.IsInPointRange(this.entity, x, z, min, max, false);
};
+/**
+ * Check if the target is inside the range.
+ *
+ * @param {number | Vector3D} target - Either the target entity ID, or a Vector3D of a position to attack.
+ * @param {iid} number - The type of the interface which ought to be queried.
+ * @param {string} type - The type of the attack which is to be used.
+ *
+ * @return {boolean} - Whether the location is within range.
+ */
UnitAI.prototype.CheckTargetRange = function(target, iid, type)
{
let range = this.GetRange(iid, type);
@@ -4664,18 +4813,26 @@
return false;
let cmpObstructionManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager);
- return cmpObstructionManager.IsInTargetRange(this.entity, target, range.min, range.max, false);
+ let attackEntity = this.TargetIsEntity(target);
+ if (attackEntity)
+ return cmpObstructionManager.IsInTargetRange(this.entity, target, range.min, range.max, false);
+ return cmpObstructionManager.IsInPointRange(this.entity, target.x, target.z, range.min, range.max, false);
};
/**
- * Check if the target is inside the attack range
- * For melee attacks, this goes straigt to the regular range calculation
+ * Check if the target is inside the attack range.
+ * For melee attacks, this goes straight to the regular range calculation.
* For ranged attacks, the parabolic formula is used to accout for bigger ranges
- * when the target is lower, and smaller ranges when the target is higher
+ * when the target is lower, and smaller ranges when the target is higher.
+ *
+ * @param {number | Vector3D} target - Either the target entity ID, or a Vector3D of a position to attack.
+ * @param {string} type - The type of the attack which is to be used.
+ *
+ * @return {boolean} - Whether the attack-location is within attacking distance.
*/
UnitAI.prototype.CheckTargetAttackRange = function(target, type)
{
- // for formation members, the formation will take care of the range check
+ // For formation members, the formation will take care of the range check.
if (this.IsFormationMember())
{
let cmpFormationUnitAI = Engine.QueryInterface(this.formationController, IID_UnitAI);
@@ -4684,37 +4841,41 @@
return true;
}
- let cmpFormation = Engine.QueryInterface(target, IID_Formation);
- if (cmpFormation)
- target = cmpFormation.GetClosestMember(this.entity);
+ let attackingEntity = this.TargetIsEntity(target);
+ if (attackingEntity)
+ {
+ let cmpFormation = Engine.QueryInterface(target, IID_Formation);
+ if (cmpFormation)
+ target = cmpFormation.GetClosestMember(this.entity);
+ }
if (type != "Ranged")
return this.CheckTargetRange(target, IID_Attack, type);
- let targetCmpPosition = Engine.QueryInterface(target, IID_Position);
- if (!targetCmpPosition || !targetCmpPosition.IsInWorld())
+ let targetPosition = this.GetTargetPosition3D(target);
+ if (!targetPosition)
return false;
let range = this.GetRange(IID_Attack, type);
if (!range)
return false;
- let thisCmpPosition = Engine.QueryInterface(this.entity, IID_Position);
- if (!thisCmpPosition.IsInWorld())
+ let cmpPositionSelf = Engine.QueryInterface(this.entity, IID_Position);
+ if (!cmpPositionSelf || !cmpPositionSelf.IsInWorld())
return false;
- let s = thisCmpPosition.GetPosition();
+ let selfPosition = cmpPositionSelf.GetPosition();
- let t = targetCmpPosition.GetPosition();
-
- let h = s.y - t.y + range.elevationBonus;
+ let h = selfPosition.y - targetPosition.y + range.elevationBonus;
let maxRange = Math.sqrt(Math.square(range.max) + 2 * range.max * h);
if (maxRange < 0)
return false;
let cmpObstructionManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager);
- return cmpObstructionManager.IsInTargetRange(this.entity, target, range.min, maxRange, false);
+ if (attackingEntity)
+ return cmpObstructionManager.IsInTargetRange(this.entity, target, range.min, maxRange, false);
+ return cmpObstructionManager.IsInPointRange(this.entity, target.x, target.z, range.min, maxRange, false);
};
UnitAI.prototype.CheckTargetRangeExplicit = function(target, min, max)
@@ -4726,14 +4887,18 @@
/**
* Check if the target is inside the attack range of the formation.
*
- * @param {number} target - The target entity ID to attack.
+ * @param {number | Vector3D} target - The target entity ID to attack, or a Vector3D of a position to attack.
* @return {boolean} - Whether the entity is within attacking distance.
*/
UnitAI.prototype.CheckFormationTargetAttackRange = function(target)
{
- let cmpTargetFormation = Engine.QueryInterface(target, IID_Formation);
- if (cmpTargetFormation)
- target = cmpTargetFormation.GetClosestMember(this.entity);
+ let attackingEntity = this.TargetIsEntity(target);
+ if (attackingEntity)
+ {
+ let cmpTargetFormation = Engine.QueryInterface(target, IID_Formation);
+ if (cmpTargetFormation)
+ target = cmpTargetFormation.GetClosestMember(this.entity);
+ }
let cmpFormationAttack = Engine.QueryInterface(this.entity, IID_Attack);
if (!cmpFormationAttack)
@@ -4741,7 +4906,9 @@
let range = cmpFormationAttack.GetRange(target);
let cmpObstructionManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager);
- return cmpObstructionManager.IsInTargetRange(this.entity, target, range.min, range.max, false);
+ if (attackingEntity)
+ return cmpObstructionManager.IsInTargetRange(this.entity, target, range.min, range.max, false);
+ return cmpObstructionManager.IsInPointRange(this.entity, target.x, target.z, range.min, range.max, false);
};
UnitAI.prototype.CheckGarrisonRange = function(target)
@@ -4761,19 +4928,26 @@
/**
* Returns true if the target entity is visible through the FoW/SoD.
+ *
+ * @param {number | Vector3D} target - Either an entity ID, or a Vector3D of a position.
+ * @return {boolean} - Whether the target is visible.
*/
UnitAI.prototype.CheckTargetVisible = function(target)
{
- var cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership);
+ // Assume an attackground target is either visible or not important whether it is.
+ if (!this.TargetIsEntity(target))
+ return true;
+
+ let cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership);
if (!cmpOwnership)
return false;
- var cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager);
+ let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager);
if (!cmpRangeManager)
return false;
- // Entities that are hidden and miraged are considered visible
- var cmpFogging = Engine.QueryInterface(target, IID_Fogging);
+ // Entities that are hidden and miraged are considered visible.
+ let cmpFogging = Engine.QueryInterface(target, IID_Fogging);
if (cmpFogging && cmpFogging.IsMiraged(cmpOwnership.GetOwner()))
return true;
@@ -4826,13 +5000,11 @@
*/
UnitAI.prototype.FaceTowardsTarget = function(target)
{
- let cmpTargetPosition = Engine.QueryInterface(target, IID_Position);
- if (!cmpTargetPosition || !cmpTargetPosition.IsInWorld())
+ let targetPosition = this.GetTargetPosition2D(target);
+ if (!targetPosition)
return;
- let targetPosition = cmpTargetPosition.GetPosition2D();
-
- // Use cmpUnitMotion for units that support that, otherwise try cmpPosition (e.g. turrets)
+ // Use cmpUnitMotion for units that support that, otherwise try cmpPosition (e.g. turrets).
let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion);
if (cmpUnitMotion)
{
@@ -5009,7 +5181,7 @@
*/
UnitAI.prototype.ShouldChaseTargetedEntity = function(target, force)
{
- if (this.IsTurret())
+ if (!this.TargetIsEntity(target) || this.IsTurret())
return false;
if (this.GetStance().respondChase)
@@ -5079,6 +5251,45 @@
this.PushOrderFront("MoveIntoFormation", { "x": pos.x, "z": pos.z, "force": true });
};
+/*
+ * Returns the 3D target position.
+ *
+ * @param {number | Vector3D} target - Either the target entity ID, or a Vector3D of a position to attack.
+ * @return {Vector3D} - The 3D-position of the target.
+ */
+UnitAI.prototype.GetTargetPosition3D = function(target)
+{
+ if (this.TargetIsEntity(target))
+ {
+ let cmpTargetPosition = Engine.QueryInterface(target, IID_Position);
+ if (!cmpTargetPosition || !cmpTargetPosition.IsInWorld())
+ return undefined;
+
+ return cmpTargetPosition.GetPosition();
+ }
+
+ return target;
+};
+
+/*
+ * Returns the 2D target position.
+ *
+ * @param {number | Vector3D} target - Either the target entity ID, or a Vector3D of a position to attack.
+ * @return {Vector3D} - The 3D-position of the target.
+ */
+UnitAI.prototype.GetTargetPosition2D = function(target)
+{
+ if (this.TargetIsEntity(target))
+ {
+ let cmpTargetPosition = Engine.QueryInterface(target, IID_Position);
+ if (!cmpTargetPosition || !cmpTargetPosition.IsInWorld())
+ return undefined;
+
+ return cmpTargetPosition.GetPosition2D();
+ }
+ return Vector2D.from3D(target);
+};
+
UnitAI.prototype.GetTargetPositions = function()
{
var targetPositions = [];
@@ -5096,6 +5307,10 @@
targetPositions.push(new Vector2D(order.data.x, order.data.z));
break; // and continue the loop
+ case "AttackGround":
+ targetPositions.push(Vector2D.from3D(order.data.target));
+ return targetPositions;
+
case "WalkToTarget":
case "WalkToTargetRange": // This doesn't move to the target (just into range), but a later order will.
case "Guard":
@@ -5381,6 +5596,23 @@
};
/**
+ * Adds AttackGround order to queue, forced by the player.
+ *
+ * @param {Vector3D} target - The x,y,z-values where the entities need to attack ground.
+ * @param {number} radius - The radius from the target in which the entities need to attack ground.
+ * @param {boolean} queued - Whether the order is queued or not.
+ */
+UnitAI.prototype.AttackGround = function(target, radius, queued)
+{
+ if (this.CanAttack(target))
+ this.AddOrder("AttackGround", {
+ "target": target,
+ "radius": radius,
+ "force": true
+ }, queued);
+};
+
+/**
* Adds garrison order to the queue, forced by the player.
*/
UnitAI.prototype.Garrison = function(target, queued)
@@ -5985,9 +6217,9 @@
{
if (!orderData)
orderData = this.order.data;
- let cmpPosition = Engine.QueryInterface(orderData.target, IID_Position);
- if (cmpPosition && cmpPosition.IsInWorld())
- orderData.lastPos = cmpPosition.GetPosition();
+ let targetPosition = this.GetTargetPosition3D(orderData.target);
+ if (targetPosition)
+ orderData.lastPos = targetPosition;
};
UnitAI.prototype.SetHeldPosition = function(x, z)
@@ -6038,7 +6270,16 @@
return undefined;
return component.GetRange(type);
-}
+};
+
+/**
+ * @param {number | Vector3D} target - Either the target entity ID, or a Vector3D of a position to attack.
+ * @return {boolean} - Whether the target is an entity.
+ */
+UnitAI.prototype.TargetIsEntity = function(target)
+{
+ return typeof target == "number";
+};
UnitAI.prototype.CanAttack = function(target)
{
Index: binaries/data/mods/public/simulation/components/tests/test_Attack.js
===================================================================
--- binaries/data/mods/public/simulation/components/tests/test_Attack.js
+++ binaries/data/mods/public/simulation/components/tests/test_Attack.js
@@ -102,7 +102,8 @@
"Multiplier": 3
}
}
- }
+ },
+ "AttackGround": {}
},
"Capture": {
"Capture": 8,
@@ -153,6 +154,10 @@
TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetFullAttackRange(), { "min": 0, "max": 80 });
TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackEffectsData("Capture"), { "Capture": 8 });
+ TS_ASSERT_UNEVAL_EQUALS(cmpAttack.CanAttackGround("Capture"), false);
+ TS_ASSERT_UNEVAL_EQUALS(cmpAttack.CanAttackGround("Ranged"), true);
+ TS_ASSERT_UNEVAL_EQUALS(cmpAttack.CanAttackGround(), true);
+
TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackEffectsData("Ranged"), {
"Damage": {
"Hack": 0,
Index: binaries/data/mods/public/simulation/helpers/Commands.js
===================================================================
--- binaries/data/mods/public/simulation/helpers/Commands.js
+++ binaries/data/mods/public/simulation/helpers/Commands.js
@@ -192,6 +192,21 @@
});
},
+ "attack-ground": function(player, cmd, data)
+ {
+ let entities = [];
+ let target = new Vector3D(cmd.target.x, cmd.target.y, cmd.target.z);
+ for (let ent of data.entities)
+ {
+ let cmpAttack = Engine.QueryInterface(ent, IID_Attack);
+ if (cmpAttack && cmpAttack.CanAttackGround())
+ entities.push(ent);
+ }
+ GetFormationUnitAIs(entities, player).forEach(cmpUnitAI => {
+ cmpUnitAI.AttackGround(target, cmd.queued);
+ });
+ },
+
"patrol": function(player, cmd, data)
{
let allowCapture = cmd.allowCapture || cmd.allowCapture == null;
Index: binaries/data/mods/public/simulation/templates/template_unit_infantry_ranged.xml
===================================================================
--- binaries/data/mods/public/simulation/templates/template_unit_infantry_ranged.xml
+++ binaries/data/mods/public/simulation/templates/template_unit_infantry_ranged.xml
@@ -25,6 +25,7 @@
Human
+