Index: binaries/data/config/default.cfg
===================================================================
--- binaries/data/config/default.cfg
+++ binaries/data/config/default.cfg
@@ -313,6 +313,8 @@
deselectgroup = Ctrl ; Modifier to deselect units when clicking group icon, instead of selecting
rotate.cw = RightBracket ; Rotate building placement preview clockwise
rotate.ccw = LeftBracket ; Rotate building placement preview anticlockwise
+attackgroundradius.decrease = PgDn ; Decrease attack ground bombard radius.
+attackgroundradius.increase = PgUp ; Increase attack ground bombard radius.
[hotkey.session.gui]
toggle = "Alt+G" ; Toggle visibility of session GUI
@@ -377,6 +379,8 @@
rankabovestatusbar = true ; Show rank icons above status bars
experiencestatusbar = true ; Show an experience status bar above each selected unit
respoptooltipsort = 0 ; Sorting players in the resources and population tooltip by value (0 - no sort, -1 - ascending, 1 - descending)
+attackgroundradius = 2.0 ; Default radius of the area to bombard with attack ground (meter).
+attackgroundradiuschange = 0.5 ; Value to add/subtract from the radius with each push of the hotkey (meter).
[gui.session.minimap]
blinkduration = 1.7 ; The blink duration while pinging
Index: binaries/data/mods/public/gui/options/options.json
===================================================================
--- binaries/data/mods/public/gui/options/options.json
+++ binaries/data/mods/public/gui/options/options.json
@@ -434,6 +434,23 @@
"max": 30
},
{
+ "type": "number",
+ "label": "Attack-Ground Bombard Size",
+ "tooltip": "The default value of the bombard radius (meter).",
+ "config": "gui.session.attackgroundradius",
+ "callback": "updateDefaultAttackGroundSize",
+ "min": 1,
+ "max": 20
+ },
+ {
+ "type": "slider",
+ "label": "Attack-Ground Increment value",
+ "tooltip": "Value to add/subtract from the radius with each push of the hotkey (meter).",
+ "config": "gui.session.attackgroundradiuschange",
+ "min": 0.1,
+ "max": 10
+ },
+ {
"type": "boolean",
"label": "Chat Notification Attack",
"tooltip": "Show a chat notification if you are attacked by another player.",
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;
@@ -881,9 +882,13 @@
case "mousebuttondown":
if (ev.button == SDL_BUTTON_LEFT && preSelectedAction != ACTION_NONE)
{
- var action = determineAction(ev.x, ev.y);
+ let action = determineAction(ev.x, ev.y);
if (!action)
break;
+
+ if (preSelectedAction == ACTION_ATTACKGROUND)
+ action.radius = g_AttackGroundSize;
+
if (!Engine.HotkeyIsPressed("session.queue"))
{
preSelectedAction = ACTION_NONE;
@@ -897,7 +902,17 @@
inputState = INPUT_NORMAL;
break;
}
- // else
+
+ case "hotkeydown":
+ if (preSelectedAction == ACTION_ATTACKGROUND && ev.hotkey == "session.attackgroundradius.decrease")
+ {
+ AttackGroundRadiusChange(-1);
+ }
+ else if (preSelectedAction == ACTION_ATTACKGROUND && ev.hotkey == "session.attackgroundradius.increase")
+ {
+ AttackGroundRadiusChange(1);
+ }
+
default:
// Slight hack: If selection is empty, reset the input state
if (g_Selection.toList().length == 0)
@@ -1292,6 +1307,34 @@
}
}
+// Attack ground:
+// When the user uses the hotkey the radius of the bombarded area is increased/decreased.
+var g_AttackGroundSize = getDefaultAttackGroundSize();
+function AttackGroundRadiusChange(dir)
+{
+ g_AttackGroundSize += dir * +Engine.ConfigDB_GetValue("user", "gui.session.attackgroundradiuschange");
+ if (g_AttackGroundSize < 0 || !Number.isFinite(g_AttackGroundSize))
+ g_AttackGroundSize = 0;
+
+ updateSelectionDetails();
+}
+
+function getDefaultAttackGroundSize()
+{
+ let num = +Engine.ConfigDB_GetValue("user", "gui.session.attackgroundradius");
+ return Number.isFinite(num) && num >= 0 ? num : 0;
+}
+
+function getAttackGroundSize()
+{
+ return Math.max(g_AttackGroundSize, 0);
+}
+
+function updateDefaultAttackGroundSize()
+{
+ g_AttackGroundSize = getDefaultAttackGroundSize();
+}
+
// Batch training:
// When the user shift-clicks, we set these variables and switch to INPUT_BATCHTRAINING
// When the user releases shift, or clicks on a different training button, we create the batched units
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
@@ -236,6 +236,48 @@
"specificness": 10,
},
+ "attack-ground": {
+ "execute": function(target, action, selection, queued)
+ {
+ Engine.PostNetworkCommand({
+ "type": "attack-ground",
+ "entities": selection,
+ "target": target,
+ "radius": action.radius,
+ "queued": queued
+ });
+
+ DrawTargetMarker(target);
+
+ return true;
+ },
+ "getActionInfo": function(selection, target)
+ {
+ let tooltip = sprintf(translate("Bombard radius: %(radius)s %(unit)s"), {
+ "radius": g_AttackGroundSize.toFixed(1),
+ "unit": translatePlural("meter", "meters", g_AttackGroundSize)
+ });
+
+ return {
+ "possible": true,
+ "tooltip": tooltip
+ };
+ },
+ "preSelectedActionCheck": function(target, selection)
+ {
+ if (preSelectedAction != ACTION_ATTACKGROUND)
+ return false;
+
+ return {
+ "type": "attack-ground",
+ "cursor": "action-attack",
+ "tooltip": getActionInfo("attack-ground", target, selection).tooltip,
+ "target": target
+ };
+ },
+ "specificness": 50,
+ },
+
"patrol":
{
"execute": function(target, action, selection, queued)
@@ -1105,6 +1147,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-request.png"
+ };
+ },
+ "execute": function(entStates)
+ {
+ inputState = INPUT_PRESELECTEDACTION;
+ preSelectedAction = ACTION_ATTACKGROUND;
+ },
+ },
+
"garrison": {
"getInfo": function(entStates)
{
@@ -1522,6 +1584,11 @@
"cursor": cursor
};
}
+ if (action == "attack-ground")
+ return {
+ "possible": true,
+ "tooltip": g_UnitActions[action].getActionInfo("attack-ground", selection).tooltip
+ }
return {
"possible": ["move", "attack-move", "remove-guard", "patrol"].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
@@ -83,6 +83,7 @@
"0.0" +
"" +
"" +
+ "" +
"" +
"" +
"" +
@@ -144,6 +145,9 @@
"" +
"" +
"" +
+ ""+
+ "" +
+ "" +
"" +
"" +
"" +
@@ -239,8 +243,24 @@
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;
@@ -355,6 +375,10 @@
*/
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)
{
@@ -439,9 +463,11 @@
};
/**
- * 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)
{
@@ -464,16 +490,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 ? 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) *
@@ -483,11 +516,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.
@@ -53,21 +53,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 cmpPlayer = QueryPlayerIDInterface(data.attackerOwner);
let ents = Attacking.EntitiesNearPoint(Vector2D.from3D(data.position), targetPosition.horizDistanceTo(data.position) * 2, cmpPlayer.GetEnemies());
for (let ent of ents)
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
@@ -23,44 +23,53 @@
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
@@ -454,6 +454,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
@@ -427,7 +427,7 @@
if (this.IsAnimal())
this.SetNextState("ANIMAL.COMBAT.ATTACKING");
else
- this.SetNextState("INDIVIDUAL.COMBAT.ATTACKING");
+ this.SetNextState("INDIVIDUAL.COMBAT");
return;
}
@@ -454,6 +454,27 @@
this.SetNextState("INDIVIDUAL.COMBAT.APPROACHING");
},
+ "Order.AttackGround": function(msg) {
+ // Work out how to attack the given target
+ let target = this.order.data.target;
+ let type = this.GetBestAttackAgainst(target);
+ if (!type)
+ {
+ // Oops, we can't attack at all
+ this.FinishOrder();
+ return;
+ }
+ this.order.data.attackType = type;
+
+ // Distribute the attacks over the area.
+ let randNorm = randomNormal2D();
+ let offsetX = randNorm[0] * this.order.data.radius;
+ let offsetZ = randNorm[1] * this.order.data.radius;
+ this.order.data.target = new Vector3D(target.x + offsetX, target.y, target.z + offsetZ);
+
+ this.SetNextState("INDIVIDUAL.COMBAT");
+ },
+
"Order.Patrol": function(msg) {
if (this.IsAnimal() || this.IsTurret())
{
@@ -723,15 +744,15 @@
},
"Order.Attack": function(msg) {
- var target = msg.data.target;
- var allowCapture = msg.data.allowCapture;
- var cmpTargetUnitAI = Engine.QueryInterface(target, IID_UnitAI);
+ let target = msg.data.target;
+ let allowCapture = msg.data.allowCapture;
+ let cmpTargetUnitAI = Engine.QueryInterface(target, IID_UnitAI);
if (cmpTargetUnitAI && cmpTargetUnitAI.IsFormationMember())
target = cmpTargetUnitAI.GetFormationController();
- var cmpAttack = Engine.QueryInterface(this.entity, IID_Attack);
+ let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack);
// Check if we are already in range, otherwise walk there
- if (!this.CheckTargetAttackRange(target, target))
+ if (!this.CheckFormationTargetAttackRange(target, target))
{
if (this.TargetIsAlive(target) && this.CheckTargetVisible(target))
{
@@ -748,6 +769,23 @@
this.SetNextState("MEMBER");
},
+ "Order.AttackGround": function(msg) {
+ let target = msg.data.target;
+
+ let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack);
+ // Check if we are already in range, otherwise walk there
+ if (!this.CheckFormationTargetAttackRange(target))
+ {
+ this.SetNextState("COMBAT.APPROACHING");
+ return;
+ }
+ this.CallMemberFunction("AttackGround", [target, msg.data.radius, false]);
+ if (cmpAttack.CanAttackAsFormation())
+ this.SetNextState("COMBAT.ATTACKING");
+ else
+ this.SetNextState("MEMBER");
+ },
+
"Order.Garrison": function(msg) {
if (!Engine.QueryInterface(msg.data.target, IID_GarrisonHolder))
{
@@ -1121,7 +1159,7 @@
"COMBAT": {
"APPROACHING": {
"enter": function() {
- if (!this.MoveTo(this.order.data))
+ if (!this.MoveFormationToTargetAttackRange(this.order.data.target))
{
this.FinishOrder();
return true;
@@ -1136,8 +1174,13 @@
},
"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 (typeof target == "number")
+ this.CallMemberFunction("Attack", [target, this.order.data.allowCapture, false]);
+ else
+ this.CallMemberFunction("AttackGround", [target, this.order.data.radius, false]);
+
if (cmpAttack.CanAttackAsFormation())
this.SetNextState("COMBAT.ATTACKING");
else
@@ -1148,23 +1191,37 @@
"ATTACKING": {
// Wait for individual members to finish
"enter": function(msg) {
- var target = this.order.data.target;
- var allowCapture = this.order.data.allowCapture;
+ let target = this.order.data.target;
+ let allowCapture = this.order.data.allowCapture;
+ let attackEntity = typeof target == "number";
// Check if we are already in range, otherwise walk there
- if (!this.CheckTargetAttackRange(target, target))
+ if (!this.CheckFormationTargetAttackRange(target))
{
- if (this.TargetIsAlive(target) && this.CheckTargetVisible(target))
+ if (!attackEntity || 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,
+ "radius": this.order.data.radius,
+ "force": false
+ });
+
return true;
}
this.FinishOrder();
return true;
}
- var cmpFormation = Engine.QueryInterface(this.entity, IID_Formation);
- // TODO fix the rearranging while attacking as formation
+ let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation);
+ // TODO fix the rearranging while attacking as formation.
cmpFormation.SetRearrange(!this.IsAttackingAsFormation());
cmpFormation.MoveMembersIntoFormation(false, false);
this.StartTimer(200, 200);
@@ -1172,15 +1229,28 @@
},
"Timer": function(msg) {
- var target = this.order.data.target;
- var allowCapture = this.order.data.allowCapture;
+ let target = this.order.data.target;
+ let allowCapture = this.order.data.allowCapture;
+ let attackEntity = typeof target == "number";
+
// Check if we are already in range, otherwise walk there
- if (!this.CheckTargetAttackRange(target, target))
+ if (!this.CheckFormationTargetAttackRange(target))
{
- if (this.TargetIsAlive(target) && this.CheckTargetVisible(target))
+ if (!attackEntity || 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,
+ "radius": this.order.data.radius,
+ "force": false
+ });
return;
}
this.FinishOrder();
@@ -1190,7 +1260,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);
},
@@ -1757,6 +1827,35 @@
},
"COMBAT": {
+ "enter": function() {
+ // 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;
+ }
+
+ this.SetNextState("ATTACKING");
+ return;
+ }
+
+ // 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;
+ }
+
+ this.SetNextState("APPROACHING");
+ },
+
"Order.LeaveFoundation": function(msg) {
// Ignore the order as we're busy.
return { "discardOrder": true };
@@ -1780,7 +1879,9 @@
// Show weapons rather than carried resources.
this.SetAnimationVariant("combat");
- this.StartTimer(1000, 1000);
+ // If attack ground is asked do not start the timer (ground does not run away).
+ if (typeof this.order.data.target == "number")
+ this.StartTimer(1000, 1000);
},
"leave": function() {
@@ -1850,13 +1951,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 attackEntity = typeof target == "number";
+ if (attackEntity)
{
- 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))
@@ -1908,6 +2013,11 @@
this.FaceTowardsTarget(this.order.data.target);
let cmpBuildingAI = Engine.QueryInterface(this.entity, IID_BuildingAI);
+ if (cmpBuildingAI && !attackEntity)
+ {
+ this.FinishOrder();
+ return true;
+ }
if (cmpBuildingAI)
cmpBuildingAI.SetUnitAITarget(this.order.data.target);
},
@@ -1923,6 +2033,7 @@
"Timer": function(msg) {
let target = this.order.data.target;
+ let attackEntity = typeof target == "number";
// Check the target is still alive and attackable
if (!this.CanAttack(target))
@@ -1970,6 +2081,11 @@
this.SetNextState("COMBAT.CHASING");
return;
}
+ else if (this.MoveToTargetAttackRange(target, this.order.data.attackType))
+ {
+ this.SetNextState("COMBAT.APPROACHING");
+ return;
+ }
this.SetNextState("FINDINGNEWTARGET");
},
@@ -4288,28 +4404,45 @@
return 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)
{
+ let attackEntity = typeof target == "number";
+
if (!this.CheckTargetVisible(target) || this.IsTurret())
return false;
- var cmpRanged = Engine.QueryInterface(this.entity, iid);
+ let cmpRanged = Engine.QueryInterface(this.entity, iid);
if (!cmpRanged)
return false;
- var range = cmpRanged.GetRange(type);
+ let range = cmpRanged.GetRange(type);
- var cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion);
- return cmpUnitMotion.MoveToTargetRange(target, range.min, range.max);
+ let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion);
+ if (attackEntity)
+ return cmpUnitMotion.MoveToTargetRange(target, range.min, range.max);
+ return cmpUnitMotion.MoveToPointRange(target.x, target.z, range.min, range.max, true);
};
/**
- * 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
+ let attackEntity = typeof target == "number";
+
+ // For formation members, the formation will take care of the range check.
if (this.IsFormationMember())
{
let cmpFormationUnitAI = Engine.QueryInterface(this.formationController, IID_UnitAI);
@@ -4317,9 +4450,12 @@
return false;
}
- let cmpFormation = Engine.QueryInterface(target, IID_Formation);
- if (cmpFormation)
- target = cmpFormation.GetClosestMember(this.entity);
+ if (attackEntity)
+ {
+ let cmpFormation = Engine.QueryInterface(target, IID_Formation);
+ if (cmpFormation)
+ target = cmpFormation.GetClosestMember(this.entity);
+ }
if (type != "Ranged")
return this.MoveToTargetRange(target, IID_Attack, type);
@@ -4330,22 +4466,23 @@
let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack);
let range = cmpAttack.GetRange(type);
- let thisCmpPosition = Engine.QueryInterface(this.entity, IID_Position);
- if (!thisCmpPosition.IsInWorld())
+ let cmpSelfPosition = Engine.QueryInterface(this.entity, IID_Position);
+ if (!cmpSelfPosition.IsInWorld())
return false;
- let s = thisCmpPosition.GetPosition();
+ let selfPosition = cmpSelfPosition.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)
+ // No negative roots please.
+ let parabolicMaxRange;
+ 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;
@@ -4353,7 +4490,10 @@
let guessedMaxRange = parabolicMaxRange > range.max ? (range.max + parabolicMaxRange) / 2 : parabolicMaxRange;
let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion);
- return cmpUnitMotion.MoveToTargetRange(target, range.min, guessedMaxRange);
+ if (attackEntity)
+ return cmpUnitMotion.MoveToTargetRange(target, range.min, guessedMaxRange);
+
+ return cmpUnitMotion.MoveToPointRange(target.x, target.z, range.min, guessedMaxRange);
};
UnitAI.prototype.MoveToTargetRangeExplicit = function(target, min, max)
@@ -4365,6 +4505,50 @@
return cmpUnitMotion.MoveToTargetRange(target, min, max);
};
+/**
+ * Move formation so we hope the target is within the attack range.
+ *
+ * @param {number | Vector3D} target - Either the target entity ID, or a Vector3D of a position to attack.
+ * @return {boolean} - Whether the order to move has succeeded?
+ */
+UnitAI.prototype.MoveFormationToTargetAttackRange = function(target)
+{
+ let attackEntity = typeof target == "number";
+ // For formation members, the formation will take care of the range check.
+ if (this.IsFormationMember())
+ {
+ let cmpFormationUnitAI = Engine.QueryInterface(this.formationController, IID_UnitAI);
+ if (cmpFormationUnitAI && cmpFormationUnitAI.IsAttackingAsFormation())
+ return false;
+ }
+
+ if (attackEntity)
+ {
+ let cmpFormation = Engine.QueryInterface(target, IID_Formation);
+ if (cmpFormation)
+ target = cmpFormation.GetClosestMember(this.entity);
+ }
+
+ if (!this.CheckTargetVisible(target) || this.IsTurret())
+ return false;
+
+ let cmpFormationAttack = Engine.QueryInterface(this.entity, IID_Attack);
+ if (!cmpFormationAttack)
+ return false;
+ let range = cmpFormationAttack.GetRange(target);
+
+ let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion);
+ if (cmpUnitMotion)
+ {
+ if (attackEntity)
+ return cmpUnitMotion.MoveToTargetRange(target, range.min, range.max);
+ else
+ return cmpUnitMotion.MoveToPointRange(target.x, target.z, range.min, range.max, true);
+ }
+
+ return false;
+};
+
UnitAI.prototype.MoveToGarrisonRange = function(target)
{
if (!this.CheckTargetVisible(target))
@@ -4407,26 +4591,44 @@
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 querried.
+ * @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)
{
- var cmpRanged = Engine.QueryInterface(this.entity, iid);
+ let attackEntity = typeof target == "number";
+ let cmpRanged = Engine.QueryInterface(this.entity, iid);
if (!cmpRanged)
return false;
- var range = cmpRanged.GetRange(type);
+ let range = cmpRanged.GetRange(type);
let cmpObstructionManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager);
- return cmpObstructionManager.IsInTargetRange(this.entity, target, range.min, range.max, false);
+ 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
+ let attackEntity = typeof target == "number";
+
+ // For formation members, the formation will take care of the range check.
if (this.IsFormationMember())
{
let cmpFormationUnitAI = Engine.QueryInterface(this.formationController, IID_UnitAI);
@@ -4435,36 +4637,40 @@
return true;
}
- let cmpFormation = Engine.QueryInterface(target, IID_Formation);
- if (cmpFormation)
- target = cmpFormation.GetClosestMember(this.entity);
+ if (attackEntity)
+ {
+ 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 cmpAttack = Engine.QueryInterface(this.entity, IID_Attack);
let range = cmpAttack.GetRange(type);
- let thisCmpPosition = Engine.QueryInterface(this.entity, IID_Position);
- if (!thisCmpPosition.IsInWorld())
+ let cmpSelfPosition = Engine.QueryInterface(this.entity, IID_Position);
+ if (!cmpSelfPosition || !cmpSelfPosition.IsInWorld())
return false;
- let s = thisCmpPosition.GetPosition();
+ let selfPosition = cmpSelfPosition.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 (attackEntity)
+ 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)
@@ -4473,6 +4679,43 @@
return cmpObstructionManager.IsInTargetRange(this.entity, target, min, max, false);
};
+/**
+ * Check if the target is inside the attack range of the formation.
+ *
+ * @param {number | Vector3D} target - Either the target entity ID, or a Vector3D of a position to attack.
+ * @return {boolean} - Whether the attack-location is within attacking distance.
+ */
+UnitAI.prototype.CheckFormationTargetAttackRange = function(target)
+{
+ let attackEntity = typeof target == "number";
+ // for formation members, the formation will take care of the range check
+ if (this.IsFormationMember())
+ {
+ let cmpFormationUnitAI = Engine.QueryInterface(this.formationController, IID_UnitAI);
+ if (cmpFormationUnitAI && cmpFormationUnitAI.IsAttackingAsFormation()
+ && cmpFormationUnitAI.order.data.target == target)
+ return true;
+ }
+
+ if (attackEntity)
+ {
+ let cmpTargetFormation = Engine.QueryInterface(target, IID_Formation);
+ if (cmpTargetFormation)
+ target = cmpTargetFormation.GetClosestMember(this.entity);
+ }
+
+ let cmpFormationAttack = Engine.QueryInterface(this.entity, IID_Attack);
+ if (!cmpFormationAttack)
+ return false;
+ let range = cmpFormationAttack.GetRange(target);
+
+ let cmpObstructionManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager);
+ 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);
+};
+
UnitAI.prototype.CheckGarrisonRange = function(target)
{
var cmpGarrisonHolder = Engine.QueryInterface(target, IID_GarrisonHolder);
@@ -4490,19 +4733,25 @@
/**
* 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 (target instanceof Vector3D)
+ 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;
@@ -4555,12 +4804,10 @@
*/
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)
let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion);
if (cmpUnitMotion)
@@ -4737,7 +4984,7 @@
*/
UnitAI.prototype.ShouldChaseTargetedEntity = function(target, force)
{
- if (this.IsTurret())
+ if (target instanceof Vector3D || this.IsTurret())
return false;
if (this.GetStance().respondChase)
@@ -4807,6 +5054,43 @@
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 (typeof target == "number")
+ {
+ 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 (typeof target == "number")
+ {
+ 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 = [];
@@ -4824,6 +5108,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":
@@ -5099,6 +5387,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)
@@ -5707,9 +6012,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)
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
@@ -101,7 +101,8 @@
"Multiplier": 3
}
}
- }
+ },
+ "AttackGround": {}
},
"Capture": {
"Capture": 8,
@@ -152,6 +153,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.radius, 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
@@ -24,6 +24,7 @@
Human
+