diff --git a/binaries/data/config/default.cfg b/binaries/data/config/default.cfg
index 20d705e603..f7539414a6 100644
--- a/binaries/data/config/default.cfg
+++ b/binaries/data/config/default.cfg
@@ -237,7 +237,7 @@ quickload = "Shift+F8"
[hotkey.camera]
reset = "R" ; Reset camera rotation to default.
-follow = "F" ; Follow the first unit in the selection
+follow = "" ; Follow the first unit in the selection
rallypointfocus = "" ; Focus the camera on the rally point of the selected building
lastattackfocus = "Space" ; Focus the camera on the last notified attack
zoom.in = Plus, NumPlus ; Zoom camera in (continuous control)
@@ -351,7 +351,7 @@ unloadturrets = "U" ; Unload turreted units.
leaveturret = "U" ; Leave turret point.
move = "" ; Modifier to move to a point instead of another action (e.g. gather)
capture = "C" ; Modifier to capture instead of another action (e.g. attack)
-attack = "" ; Modifier to attack instead of another action (e.g. capture)
+attack = "F" ; Modifier to attack instead of another action (e.g. capture)
attackmove = Ctrl ; Modifier to attackmove when clicking on a point
attackmoveUnit = "Ctrl+Q" ; Modifier to attackmove targeting only units when clicking on a point
garrison = Ctrl ; Modifier to garrison when clicking on building
@@ -377,6 +377,7 @@ toggledefaultformation = "" ; Switch between null default formation and the las
flare = K ; Modifier to send a flare to your allies
flareactivate = "" ; Modifier to activate the mode to send a flare to your allies
calltoarms = "" ; Modifier to call the selected units to the arms.
+focusfire = "F" ; Modifier to control exclusively a building's arrows if it can attack
; Overlays
showstatusbars = Tab ; Toggle display of status bars
devcommands.toggle = "Alt+D" ; Toggle developer commands panel
diff --git a/binaries/data/mods/public/art/textures/cursors/action-target.png b/binaries/data/mods/public/art/textures/cursors/action-target.png
new file mode 100644
index 0000000000..5dea77b50e
--- /dev/null
+++ b/binaries/data/mods/public/art/textures/cursors/action-target.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:5095290f717f89b2f00a45dac4e4bbafc2c12879e3b50897e57e384e7efec3ee
+size 1690
diff --git a/binaries/data/mods/public/art/textures/cursors/action-target.txt b/binaries/data/mods/public/art/textures/cursors/action-target.txt
new file mode 100644
index 0000000000..2fb73a07ec
--- /dev/null
+++ b/binaries/data/mods/public/art/textures/cursors/action-target.txt
@@ -0,0 +1 @@
+1 1
diff --git a/binaries/data/mods/public/art/textures/ui/tips/building_control.png b/binaries/data/mods/public/art/textures/ui/tips/building_control.png
new file mode 100644
index 0000000000..da840a5ac6
--- /dev/null
+++ b/binaries/data/mods/public/art/textures/ui/tips/building_control.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:88f38275bfb85bb5e724d974e639d71f94c5625f2e21ec0a9fbbe1636399fbe2
+size 522853
diff --git a/binaries/data/mods/public/audio/attack/weapon/bow_launch_building.xml b/binaries/data/mods/public/audio/attack/weapon/bow_launch_building.xml
new file mode 100644
index 0000000000..34138c024c
--- /dev/null
+++ b/binaries/data/mods/public/audio/attack/weapon/bow_launch_building.xml
@@ -0,0 +1,13 @@
+
+
+ 10
+ 1
+ 0
+ 1
+ 1
+ 1.2
+ 0.8
+ 500
+ audio/attack/weapon/
+ sling_210.ogg
+
diff --git a/binaries/data/mods/public/gui/hotkeys/spec/ingame.json b/binaries/data/mods/public/gui/hotkeys/spec/ingame.json
index 933ed51546..faf01cc182 100644
--- a/binaries/data/mods/public/gui/hotkeys/spec/ingame.json
+++ b/binaries/data/mods/public/gui/hotkeys/spec/ingame.json
@@ -182,6 +182,10 @@
"session.calltoarms": {
"name": "Call to arms",
"desc": "Send the selected units on attack move to the specified location after dropping resources."
+ },
+ "session.focusfire": {
+ "name": "Focus Fire",
+ "desc": "Exclusively control a building's arrows without setting a rallypoint."
}
}
}
diff --git a/binaries/data/mods/public/gui/reference/tips/texts/building_control.txt b/binaries/data/mods/public/gui/reference/tips/texts/building_control.txt
new file mode 100644
index 0000000000..7f243de3f5
--- /dev/null
+++ b/binaries/data/mods/public/gui/reference/tips/texts/building_control.txt
@@ -0,0 +1,4 @@
+CONTROLLING BUILDINGS
+You can control a building's arrows independently from its rally point:
+Right-click with the hotkey "Focus Fire" ("F" by default) to direct all arrow fire to a target unit or building. (Left)
+Right-click alone to set the rally point on a unit. (Right)
diff --git a/binaries/data/mods/public/gui/reference/tips/tipfiles.json b/binaries/data/mods/public/gui/reference/tips/tipfiles.json
index 3436b123b0..6b4acfa1de 100644
--- a/binaries/data/mods/public/gui/reference/tips/tipfiles.json
+++ b/binaries/data/mods/public/gui/reference/tips/tipfiles.json
@@ -41,6 +41,12 @@
"briton_war_dog.png"
]
},
+ {
+ "textFile": "building_control.txt",
+ "imageFiles": [
+ "building_control.png"
+ ]
+ },
{
"textFile": "carth_sacred_band.txt",
"imageFiles": [
diff --git a/binaries/data/mods/public/gui/session/unit_actions.js b/binaries/data/mods/public/gui/session/unit_actions.js
index ca4880f9b5..d3f59cb31c 100644
--- a/binaries/data/mods/public/gui/session/unit_actions.js
+++ b/binaries/data/mods/public/gui/session/unit_actions.js
@@ -1027,6 +1027,86 @@ var g_UnitActions =
"specificness": 41,
},
+ "focus-fire":
+ {
+ "execute": function(position, action, selection, queued, pushFront)
+ {
+ Engine.PostNetworkCommand({
+ "type": "focus-fire",
+ "entities": selection,
+ "x": position.x,
+ "z": position.z,
+ "target": action.target,
+ "data": action.data,
+ "queued": queued,
+ "pushFront": pushFront
+ });
+
+ if (action.data.sound)
+ Engine.GuiInterfaceCall("PlaySound", {
+ "name": "focus_fire",
+ "entity": action.firstAbleEntity
+ });
+
+ return true;
+ },
+ "getActionInfo": function(entState, targetState)
+ {
+ if (!entState.rallyPoint)
+ return false;
+
+ let tooltip;
+ let data = { "command": "walk" };
+ let cursor = "";
+ data.sound = false;
+
+ if (entState.attack && Engine.HotkeyIsPressed("session.focusfire"))
+ {
+ cursor = "action-target";
+ data.command = "attack-only";
+ if (targetState && playerCheck(entState, targetState, ["Enemy"]) && !targetState.resourceSupply)
+ {
+ data.target = targetState.id;
+ data.sound = true;
+ }
+ return {
+ "possible": true,
+ "data": data,
+ "position": targetState && targetState.position,
+ "cursor": cursor,
+ "tooltip": tooltip
+ };
+ }
+ },
+ "hotkeyActionCheck": function(target, selection)
+ {
+ // Hotkeys are checked in the actionInfo.
+ return this.actionCheck(target, selection);
+ },
+ "actionCheck": function(target, selection)
+ {
+ // We want commands to units take precedence.
+ if (selection.some(ent => {
+ let entState = GetEntityState(ent);
+ return entState && !!entState.unitAI;
+ }))
+ return false;
+
+ let actionInfo = getActionInfo("focus-fire", target, selection);
+
+ return actionInfo.possible && {
+ "type": "focus-fire",
+ "cursor": actionInfo.cursor,
+ "data": actionInfo.data,
+ "target": target,
+ "tooltip": actionInfo.tooltip,
+ "position": actionInfo.position,
+ "firstAbleEntity": actionInfo.entity
+ };
+ },
+ "specificness": 6,
+ },
+
"set-rallypoint":
{
"execute": function(position, action, selection, queued, pushFront)
diff --git a/binaries/data/mods/public/simulation/components/BuildingAI.js b/binaries/data/mods/public/simulation/components/BuildingAI.js
index 634fc9df9b..377a7fadc8 100644
--- a/binaries/data/mods/public/simulation/components/BuildingAI.js
+++ b/binaries/data/mods/public/simulation/components/BuildingAI.js
@@ -1,5 +1,5 @@
// Number of rounds of firing per 2 seconds.
-const roundCount = 10;
+const roundCount = 20;
const attackType = "Ranged";
function BuildingAI() {}
@@ -28,6 +28,7 @@ BuildingAI.prototype.Init = function()
this.archersGarrisoned = 0;
this.arrowsLeft = 0;
this.targetUnits = [];
+ this.focusTargets = [];
};
BuildingAI.prototype.OnGarrisonedUnitsChanged = function(msg)
@@ -50,6 +51,7 @@ BuildingAI.prototype.OnGarrisonedUnitsChanged = function(msg)
BuildingAI.prototype.OnOwnershipChanged = function(msg)
{
this.targetUnits = [];
+ this.focusTargets = [];
this.SetupRangeQuery();
this.SetupGaiaRangeQuery();
};
@@ -266,6 +268,22 @@ BuildingAI.prototype.SetUnitAITarget = function(ent)
this.StartTimer();
};
+/**
+ * Adds index to keep track of the user-targeted units supporting a queue
+ * @param {ent} - Target of focus-fire from unit-actions if the selection is an enemy.
+ */
+BuildingAI.prototype.AddFocusTarget = function(ent, queued, push)
+{
+ if (!ent || this.targetUnits.indexOf(ent) === -1)
+ return;
+ if (queued)
+ this.focusTargets.push({"entityId": ent});
+ else if (push)
+ this.focusTargets.unshift({"entityId": ent});
+ else
+ this.focusTargets = [{"entityId": ent}];
+};
+
/**
* Fire arrows with random temporal distribution on prefered targets.
* Called 'roundCount' times every 'RepeatTime' seconds when there are units in the range.
@@ -298,8 +316,9 @@ BuildingAI.prototype.FireArrows = function()
arrowsToFire = this.arrowsLeft;
else
arrowsToFire = Math.min(
- randIntInclusive(0, 2 * this.GetArrowCount() / roundCount),
- this.arrowsLeft
+ // shooting arrows in the first quarter of rounds results in a burst.
+ this.GetArrowCount() / (roundCount / 4),
+ this.arrowsLeft
);
if (arrowsToFire <= 0)
@@ -308,25 +327,38 @@ BuildingAI.prototype.FireArrows = function()
return;
}
- // Add targets to a weighted list, to allow preferences.
- let targets = new WeightedList();
- let maxPreference = this.MAX_PREFERENCE_BONUS;
- let addTarget = function(target)
+ // Add targets to a list.
+ let targets = [];
+ let addTarget = function(target)
{
- let preference = cmpAttack.GetPreference(target);
- let weight = 1;
-
- if (preference !== null && preference !== undefined)
- weight += maxPreference / (1 + preference);
-
- targets.push(target, weight);
+ const pref = (cmpAttack.GetPreference(target) ?? 49);
+ targets.push({"entityId": target, "preference": pref});
};
// Add the UnitAI target separately, as the UnitMotion and RangeManager implementations differ.
if (this.unitAITarget && this.targetUnits.indexOf(this.unitAITarget) == -1)
addTarget(this.unitAITarget);
- for (let target of this.targetUnits)
- addTarget(target);
+
+ else if (this.unitAITarget && this.targetUnits.indexOf(this.unitAITarget) != -1)
+ this.focusTargets = [{"entityId": this.unitAITarget}];
+
+ if (!this.focusTargets.length)
+ {
+ for (let target of this.targetUnits)
+ addTarget(target);
+ // Sort targets by preference and then by proximity.
+ targets.sort( (a,b) => {
+ if (a.preference > b.preference)
+ return 1;
+ else if (a.preference < b.preference)
+ return -1;
+ else if (PositionHelper.DistanceBetweenEntities(this.entity, a.entityId) > PositionHelper.DistanceBetweenEntities(this.entity, b.entityId))
+ return 1;
+ return -1;
+ });
+ }
+ else
+ targets = this.focusTargets;
// The obstruction manager performs approximate range checks.
// so we need to verify them here.
@@ -335,10 +367,12 @@ BuildingAI.prototype.FireArrows = function()
const range = cmpAttack.GetRange(attackType);
const yOrigin = cmpAttack.GetAttackYOrigin(attackType);
- let firedArrows = 0;
- while (firedArrows < arrowsToFire && targets.length())
- {
- const selectedTarget = targets.randomItem();
+ let firedArrows = 0;
+ let targetIndex = 0;
+ while (firedArrows < arrowsToFire && targetIndex < targets.length)
+ {
+
+ let selectedTarget = targets[targetIndex].entityId;
if (this.CheckTargetVisible(selectedTarget) && cmpObstructionManager.IsInTargetParabolicRange(
this.entity,
selectedTarget,
@@ -350,13 +384,11 @@ BuildingAI.prototype.FireArrows = function()
cmpAttack.PerformAttack(attackType, selectedTarget);
PlaySound("attack_" + attackType.toLowerCase(), this.entity);
++firedArrows;
- continue;
}
-
- // Could not attack target, try a different target.
- targets.remove(selectedTarget);
+ else
+ ++targetIndex;// Could not attack target, try a different target.
}
-
+ targets.splice(0, targetIndex);
this.arrowsLeft -= firedArrows;
++this.currentRound;
};
diff --git a/binaries/data/mods/public/simulation/helpers/Commands.js b/binaries/data/mods/public/simulation/helpers/Commands.js
index 8bd77366fd..d8d5cd0620 100644
--- a/binaries/data/mods/public/simulation/helpers/Commands.js
+++ b/binaries/data/mods/public/simulation/helpers/Commands.js
@@ -431,6 +431,14 @@ var g_Commands = {
}
},
+ "focus-fire": function (player, cmd, data)
+ {
+ for (let ent of data.entities)
+ {
+ Engine.QueryInterface(ent, IID_BuildingAI)?.AddFocusTarget(cmd.target, cmd.queued, cmd.pushFront);
+ }
+ },
+
"set-rallypoint": function(player, cmd, data)
{
for (let ent of data.entities)
diff --git a/binaries/data/mods/public/simulation/templates/template_structure.xml b/binaries/data/mods/public/simulation/templates/template_structure.xml
index 3eea9cf8b3..478cfc8f61 100644
--- a/binaries/data/mods/public/simulation/templates/template_structure.xml
+++ b/binaries/data/mods/public/simulation/templates/template_structure.xml
@@ -139,6 +139,7 @@
interface/alarm/alarm_attacked_gaia.xml
interface/alarm/alarm_attackplayer.xml
interface/alarm/alarm_attacked_gaia.xml
+ attack/weapon/bow_launch_building.xml