From e1c4e3bd423083fda86876acc25906952acc6b4d Mon Sep 17 00:00:00 2001 From: Atrik Date: Sun, 9 Nov 2025 13:52:02 +0100 Subject: [PATCH] Enable using SelectionBox for targeting Introduce a new input, a command handler and adapt UnitAI to handle arrays of targets in a single order --- .../data/mods/public/gui/session/input.js | 181 +++++++- .../public/simulation/components/UnitAI.js | 429 +++++++++++++++++- .../components/tests/test_UnitAI.js | 14 +- .../public/simulation/helpers/Commands.js | 22 + 4 files changed, 623 insertions(+), 23 deletions(-) diff --git a/binaries/data/mods/public/gui/session/input.js b/binaries/data/mods/public/gui/session/input.js index 2585b80c16..a02096dddd 100644 --- a/binaries/data/mods/public/gui/session/input.js +++ b/binaries/data/mods/public/gui/session/input.js @@ -500,6 +500,49 @@ var unitFilters = { } }; +// Choose, inside a list of entities, which ones are targetable foes for boxtargeting. +// We also filter out entities that aren't visible, or low priority like livestocks +function getTargetableEntities(ents) +{ + const player = g_ViewedPlayer; + const simState = GetSimState(); + const isEnemy = simState.players[player].isEnemy; + + // Filter valid target candidates + const candidates = ents.filter(entity => + { + const entState = GetEntityState(entity); + return entState && + unitFilters.isUnit(entity) && + entState.visibility != "hidden"; + }); + + // Enemy players + const enemyPlayers = candidates.filter(entity => + { + const entState = GetEntityState(entity); + return isEnemy[entState.player] && entState.player !== 0 && !hasClass(entState, "Domestic"); + }); + if (enemyPlayers.length > 0) + return enemyPlayers; + + // Gaia + const gaiaUnits = candidates.filter(entity => + { + const entState = GetEntityState(entity); + return entState.player === 0; + }); + if (gaiaUnits.length > 0) + return gaiaUnits; + + // Domestic animals + return candidates.filter(entity => + { + const entState = GetEntityState(entity); + return hasClass(entState, "Domestic"); + }); +} + // Choose, inside a list of entities, which ones will be selected. // We may use several entity filters, until one returns at least one element. function getPreferredEntities(ents) @@ -565,16 +608,19 @@ function handleInputBeforeGui(ev, hoveredObject) case INPUT_BANDBOXING: { const bandbox = Engine.GetGUIObjectByName("bandbox"); + const isAttackMode = Engine.HotkeyIsPressed("session.attack") && !g_IsObserver; switch (ev.type) { case "mousemotion": { const rect = updateBandbox(bandbox, ev, false); - const ents = Engine.PickPlayerEntitiesInRect(rect[0], rect[1], rect[2], rect[3], g_ViewedPlayer); - const preferredEntities = getPreferredEntities(ents); - g_Selection.setHighlightList(preferredEntities); + const player = isAttackMode ? -1 : g_ViewedPlayer; + const ents = Engine.PickPlayerEntitiesInRect(rect[0], rect[1], rect[2], rect[3], player); + const highlightEntities = isAttackMode ? getTargetableEntities(ents) : getPreferredEntities(ents); + + g_Selection.setHighlightList(highlightEntities); return false; } @@ -582,6 +628,30 @@ function handleInputBeforeGui(ev, hoveredObject) if (ev.button == SDL_BUTTON_LEFT) { const rect = updateBandbox(bandbox, ev, true); + if (isAttackMode) + { + // Box attack behavior + const ents = Engine.PickPlayerEntitiesInRect(rect[0], rect[1], rect[2], rect[3], -1); + + // Filter to only enemy units + const enemyUnits = getTargetableEntities(ents); + + // Get currently selected friendly units + const selectedUnits = g_Selection.toList(); + + if (selectedUnits.length && enemyUnits.length) + { + // Sort entities by position + const sortedCombattants = sortEntitiesForEngagement(selectedUnits, enemyUnits); + // Distribute attack orders + distributeAttackOrders(sortedCombattants.attackers, sortedCombattants.targets); + } + g_Selection.setHighlightList([]); + inputState = INPUT_NORMAL; + + return true; + } + const ents = getPreferredEntities(Engine.PickPlayerEntitiesInRect(rect[0], rect[1], rect[2], rect[3], g_ViewedPlayer)); g_Selection.setHighlightList([]); @@ -1887,3 +1957,108 @@ function clearSelection() g_Selection.reset(); preSelectedAction = ACTION_NONE; } + +function distributeAttackOrders(attackers, targets) +{ + if (targets.length < 1) + return; + + // Play attack sounds from a sample of units in selection + const soundCount = Math.min(3, attackers.length); + for (let i = 0; i < soundCount; i++) + { + const t = soundCount === 1 ? 0 : i / (soundCount - 1); + const attackerIndex = Math.floor(t * (attackers.length - 1)); + + setTimeout(() => + { + Engine.GuiInterfaceCall("PlaySound", { + "name": "order_attack", + "entity": attackers[attackerIndex] + }); + }, i * 180); + } + + Engine.PostNetworkCommand({ + "type": "attack-group", + "entities": attackers, + "targets": targets, + "queued": Engine.HotkeyIsPressed("session.queue"), + "allowCapture": false + }); +} + +/** + * Sorts attackers and targets for left-to-right engagement across the battle line. + * + * Entities are sorted by their perpendicular distance to the line connecting the + * two army centers, creating natural pairings across the battle front. + * + * @param {number[]} attackers - Array of attacking entity IDs. + * @param {number[]} targets - Array of target entity IDs. + * @returns {Object} { attackers: number[], targets: number[] } - Both arrays sorted + * for cross-line engagement (targets automatically reversed). + */ +function sortEntitiesForEngagement(attackers, targets) +{ + const getPosition = (id) => GetEntityState(id).position; + + const computeAveragePosition = (entities) => + { + if (entities.length === 0) return new Vector2D(0, 0); + const vectors = entities.map(id => Vector2D.from3D(getPosition(id))); + return Vector2D.average(vectors); + }; + + // Calculate the axis of engagement - the line between the groups' average positions + const avgAttackers = computeAveragePosition(attackers); + const avgTargets = computeAveragePosition(targets); + + // Sort each group by perpendicular distance to the engagement line + const sortedAttackers = sortEntitiesAlongLine(attackers, avgAttackers, avgTargets); + const sortedTargets = sortEntitiesAlongLine(targets, avgAttackers, avgTargets); + + // Reverse targets so the leftmost attacker pairs with leftmost target + return { + "attackers": sortedAttackers, + "targets": sortedTargets.reverse() + }; +} + +/** + * Sorts entity IDs by their projection onto a line's direction or perpendicular axis. + * + * @param {number[]} entities - Array of entity IDs. + * @param {Vector2D} lineStart - Start point of the reference line. + * @param {Vector2D} lineEnd - End point of the reference line. + * @param {boolean} sortByDirection - If true, sort by parallel projection (along line); + * If false, sort by perpendicular distance (default). + * @returns {number[]} Sorted entity IDs. + */ +function sortEntitiesAlongLine(entities, lineStart, lineEnd, sortByDirection = false) +{ + const dir = lineEnd.sub(lineStart); + const length = dir.length(); + if (length === 0) + return entities.slice(); + + // Choose basis vector: either parallel or perpendicular to the line + let basis; + if (sortByDirection) + basis = dir.mult(1 / length); // Unit direction vector + else + basis = new Vector2D(-dir.y, dir.x).normalize(); // Perpendicular + + const withCoord = entities.map(id => + { + const pos = GetEntityState(id).position; + const point2D = Vector2D.from3D(pos); + const rel = point2D.sub(lineStart); + const coord = rel.dot(basis); // Project onto basis vector + return { id, coord }; + }); + + // Sort by that coordinate + withCoord.sort((a, b) => a.coord - b.coord); + return withCoord.map(item => item.id); +} \ No newline at end of file diff --git a/binaries/data/mods/public/simulation/components/UnitAI.js b/binaries/data/mods/public/simulation/components/UnitAI.js index 40097c42cc..6a64a8e28a 100644 --- a/binaries/data/mods/public/simulation/components/UnitAI.js +++ b/binaries/data/mods/public/simulation/components/UnitAI.js @@ -424,10 +424,42 @@ UnitAI.prototype.UnitFsmSpec = { "Order.Attack": function(msg) { - const type = this.GetBestAttackAgainst(msg.data.target, msg.data.allowCapture); - if (!type) - return this.FinishOrder(); + // Check if this is a group attack + const isGroupAttack = !!msg.data.targetArray; + // For group attacks with invalid initial target, find one from the array + if (isGroupAttack && (!msg.data.target || msg.data.target === INVALID_ENTITY)) + { + if (!this.FindAndSetNextTarget(msg.data)) + { + this.FinishOrder(); + return ACCEPT_ORDER; + } + } + + // Get the attack type for the current target + const type = this.GetBestAttackAgainst(msg.data.target, msg.data.allowCapture); + + if (!type) + { + // Current target can't be attacked + if (isGroupAttack) + { + if (this.FindAndSetNextTarget(msg.data)) + { + msg.data.attackType = this.GetBestAttackAgainst(msg.data.target, msg.data.allowCapture); + if (msg.data.attackType) + { + this.SetNextState("INDIVIDUAL.COMBAT.ATTACKING"); + return ACCEPT_ORDER; + } + } + } + this.FinishOrder(); + return ACCEPT_ORDER; + } + + // Set attackType before range check msg.data.attackType = type; this.RememberTargetPosition(); @@ -1366,6 +1398,12 @@ UnitAI.prototype.UnitFsmSpec = { // Wait for individual members to finish "enter": function(msg) { + if (this.order.data.targetArray) + { + // For group attacks, we're done once we distributed targets + this.FinishOrder(); + return true; + } const target = this.order.data.target; if (!this.CheckFormationTargetAttackRange(target)) { @@ -1602,6 +1640,26 @@ UnitAI.prototype.UnitFsmSpec = { "INDIVIDUAL": { "Attacked": function(msg) { + // First, check if we have a group attack order and the attacker is in our target array + if (this.order?.type === "Attack" && + this.order.data?.targetArray?.includes(msg.data.attacker)) + { + // Switch to attack the entity that's attacking us + this.order.data.target = msg.data.attacker; + this.order.data.attackType = this.GetBestAttackAgainst(msg.data.attacker, this.order.data.allowCapture); + + // Update the current target index + const currentIndex = this.order.data.targetArray.indexOf(msg.data.attacker); + if (currentIndex !== -1) + this.order.data.currentTargetIndex = currentIndex; + + // Make sure we're in the right state to handle this + const currentState = this.GetCurrentState(); + if (currentState === "INDIVIDUAL.IDLE" || currentState === "INDIVIDUAL.WALKING") + this.SetNextState("INDIVIDUAL.COMBAT.APPROACHING"); + return; + } + if (this.GetStance().targetAttackersAlways || !this.order || !this.order.data || !this.order.data.force) this.RespondToTargetedEntities([msg.data.attacker]); }, @@ -2150,6 +2208,35 @@ UnitAI.prototype.UnitFsmSpec = { "Attacked": function(msg) { + // Check if we're doing a group attack and the attacker is in our target array + if (!this.order.data.targetArray || + !this.order.data.targetArray.includes(msg.data.attacker) || + msg.data.attacker === this.order.data.target) + return; + + // Switch to attack the entity that's attacking us + this.order.data.target = msg.data.attacker; + this.order.data.attackType = this.GetBestAttackAgainst(msg.data.attacker, this.order.data.allowCapture); + + // Update the current target index + const currentIndex = this.order.data.targetArray.indexOf(msg.data.attacker); + if (currentIndex !== -1) + this.order.data.currentTargetIndex = currentIndex; + + // Re-evaluate our approach to the new target + if (this.CheckTargetAttackRange(this.order.data.target, this.order.data.attackType)) + { + if (this.CanUnpack()) + { + this.PushOrderFront("Unpack", { "force": true }); + return; + } + this.SetNextState("ATTACKING"); + } + // Continue approaching the new target + else if (!this.MoveToTargetAttackRange(this.order.data.target, this.order.data.attackType)) + this.FinishOrder(); + // If we're already in combat mode, ignore anyone else who's attacking us // unless it's a melee attack since they may be blocking our way to the target if (msg.data.type == "Melee" && (this.GetStance().targetAttackersAlways || !this.order.data.force)) @@ -2209,7 +2296,23 @@ UnitAI.prototype.UnitFsmSpec = { "MovementUpdate": function(msg) { - if (msg.likelyFailure) + if (!msg.likelyFailure) + { + if (!this.CheckTargetAttackRange(this.order.data.target, this.order.data.attackType)) + return; + + if (this.CanUnpack()) + { + this.PushOrderFront("Unpack", { "force": true }); + return; + } + + this.SetNextState("ATTACKING"); + return; + } + + // For group attacks, try next target instead of moving to last position + if (!this.order.data.targetArray || this.order.data.currentTargetIndex === undefined) { // This also handles hunting. if (this.orderQueue.length > 1) @@ -2217,11 +2320,13 @@ UnitAI.prototype.UnitFsmSpec = { this.FinishOrder(); return; } - else if (!this.order.data.force || !this.order.data.lastPos) + + if (!this.order.data.force || !this.order.data.lastPos) { this.SetNextState("COMBAT.FINDINGNEWTARGET"); return; } + // If the order was forced, try moving to the target position, // under the assumption that this is desirable if the target // was somewhat far away - we'll likely end up closer to where @@ -2234,21 +2339,30 @@ UnitAI.prototype.UnitFsmSpec = { return; } - if (this.CheckTargetAttackRange(this.order.data.target, this.order.data.attackType)) + // Handle group attack case + // Remove current unreachable target from array + const currentIndex = this.order.data.currentTargetIndex; + if (currentIndex < this.order.data.targetArray.length) + this.order.data.targetArray.splice(currentIndex, 1); + + // Try to find next valid target + if (this.order.data.targetArray.length === 0 || !this.FindAndSetNextTarget(this.order.data)) { - if (this.CanUnpack()) - { - this.PushOrderFront("Unpack", { "force": true }); - return; - } - this.SetNextState("ATTACKING"); + this.FinishOrder(); + return; } - else if (msg.likelySuccess) - // Try moving again, - // attack range uses a height-related formula and our actual max range might have changed. - if (!this.MoveToTargetAttackRange(this.order.data.target, this.order.data.attackType)) - this.FinishOrder(); - }, + + this.order.data.attackType = this.GetBestAttackAgainst(this.order.data.target, this.order.data.allowCapture); + + // Try approaching the new target + if (this.MoveToTargetAttackRange(this.order.data.target, this.order.data.attackType)) + return; + + // If we can't approach the new target either, remove it and continue + this.order.data.targetArray.splice(this.order.data.currentTargetIndex, 1); + if (this.order.data.targetArray.length === 0) + this.FinishOrder(); + } }, "ATTACKING": { @@ -2322,6 +2436,18 @@ UnitAI.prototype.UnitFsmSpec = { "OutOfRange": function() { + // Check if the target is garrisoned or in a turret (turreted entities cannot be attacked by melee units) + const cmpTargetUnitAI = Engine.QueryInterface(this.order.data.target, IID_UnitAI); + const isTargetGarrisoned = cmpTargetUnitAI && cmpTargetUnitAI.isGarrisoned; + const isTargetInTurret = cmpTargetUnitAI && cmpTargetUnitAI.IsTurret(); + + // If target is garrisoned or in a turret (and we're not ranged), treat as invalid + if (isTargetGarrisoned || (isTargetInTurret && this.order.data.attackType !== "Ranged")) + { + this.ProcessMessage("TargetInvalidated"); + return; + } + if (this.ShouldChaseTargetedEntity(this.order.data.target, this.order.data.force)) { if (this.CanPack()) @@ -2332,11 +2458,53 @@ UnitAI.prototype.UnitFsmSpec = { this.SetNextState("CHASING"); return; } + + // For group attacks, try next target instead of finding new ones + if (this.order.data.targetArray && this.order.data.currentTargetIndex !== undefined) + { + const currentIndex = this.order.data.currentTargetIndex; + + // Remove current target since it's out of range and we can't chase + this.order.data.targetArray.splice(currentIndex, 1); + + // Try to find next valid target in sequence + if (this.order.data.targetArray.length > 0 && this.FindAndSetNextTarget(this.order.data)) + { + this.order.data.attackType = this.GetBestAttackAgainst(this.order.data.target, this.order.data.allowCapture); + this.SetNextState("INDIVIDUAL.COMBAT.ATTACKING"); + return; + } + } + this.SetNextState("FINDINGNEWTARGET"); }, "TargetInvalidated": function() { + // For group attacks, remove current target and move to next in array + if (this.order.data.targetArray && this.order.data.currentTargetIndex !== undefined) + { + // Remove the current target from the array + const currentIndex = this.order.data.currentTargetIndex; + if (currentIndex < this.order.data.targetArray.length) + { + this.order.data.targetArray.splice(currentIndex, 1); + + // If we have more targets, continue with the next one + if (this.order.data.targetArray.length > 0) + { + // Try to find next valid target in sequence + if (this.FindAndSetNextTarget(this.order.data)) + { + this.order.data.attackType = this.GetBestAttackAgainst(this.order.data.target, this.order.data.allowCapture); + this.SetNextState("INDIVIDUAL.COMBAT.ATTACKING"); + return; + } + } + } + } + + // No more targets or not a group attack - use original behavior this.SetNextState("FINDINGNEWTARGET"); }, @@ -2360,7 +2528,26 @@ UnitAI.prototype.UnitFsmSpec = { "enter": function() { - // Check if we are attacking a formation. + // First, check if we have pending targets in a group attack + if (this.order.data.targetArray && this.order.data.currentTargetIndex !== undefined) + { + // Try to find next valid target from the array + if (this.FindAndSetNextTarget(this.order.data)) + { + this.order.data.attackType = this.GetBestAttackAgainst(this.order.data.target, this.order.data.allowCapture); + this.SetNextState("COMBAT.ATTACKING"); + return true; + } + + // No valid targets in array - clean up and continue with normal behavior + this.order.data.targetArray = []; + this.order.data.currentTargetIndex = undefined; + } + + if (!this.order.data.target) + return false; + + // Try to find the formation the target was a part of. let cmpFormation = Engine.QueryInterface(this.order.data.target, IID_Formation); if (cmpFormation) this.order.data.formationTarget = this.order.data.target; @@ -2468,6 +2655,12 @@ UnitAI.prototype.UnitFsmSpec = { { if (msg.likelyFailure) { + // If this is a group attack with pending targets, try the next one + if (this.order.data.targetArray && this.order.data.targetArray.length > 0) + { + this.SetNextState("COMBAT.FINDINGNEWTARGET"); + return; + } // This also handles hunting. if (this.orderQueue.length > 1) { @@ -3887,6 +4080,9 @@ UnitAI.prototype.OnOwnershipChanged = function(msg) UnitAI.prototype.OnDestroy = function() { + // Clean up all group attack states before destruction + this.CleanupAllGroupAttackStates(); + // Switch to an empty state to let states execute their leave handlers. this.UnitFsm.SwitchToNextState(this, ""); @@ -4093,6 +4289,9 @@ UnitAI.prototype.FsmStateNameChanged = function(state) */ UnitAI.prototype.FinishOrder = function() { + // Clean up any group attack state before removing order + this.CleanupGroupAttackState(this.order); + if (!this.orderQueue.length) { const stack = new Error().stack.trimRight().replace(/^/mg, ' '); // indent each line @@ -4318,15 +4517,24 @@ UnitAI.prototype.ReplaceOrder = function(type, data) const idx = this.orderQueue.findIndex(o => o.type == "LeaveFormation"); if (idx === -1) { + // Clean up all queued orders before clearing them + this.CleanupAllGroupAttackStates(); this.orderQueue = []; this.order = undefined; } else + { + // Clean up orders being discarded + for (let i = 0; i < idx; ++i) + this.CleanupGroupAttackState(this.orderQueue[i]); this.orderQueue.splice(0, idx); + } this.PushOrderFront(type, data); } else { + // Clean up all queued orders before clearing them + this.CleanupAllGroupAttackStates(); this.orderQueue = []; this.PushOrder(type, data); } @@ -4405,6 +4613,7 @@ UnitAI.prototype.BackToWork = function() if (this.IsTurret(cmpTurretable) && !cmpTurretable.LeaveTurret()) return false; + this.CleanupAllGroupAttackStates(); this.orderQueue = []; this.AddOrders(this.workOrders); @@ -5060,6 +5269,9 @@ UnitAI.prototype.CheckTargetRange = function(target, iid, type) */ UnitAI.prototype.CheckTargetAttackRange = function(target, type) { + if (!target || target === INVALID_ENTITY || !type) + return false; + // for formation members, the formation will take care of the range check if (this.IsFormationMember()) { @@ -5793,6 +6005,75 @@ UnitAI.prototype.Attack = function(target, allowCapture = this.DEFAULT_CAPTURE, this.AddOrder("Attack", order, queued, pushFront); }; +/** + * Adds a group attack order to the queue. + * Used when some unit(s) are ordered to attack multiple targets. + * Each unit receives the full target list (from Commands) but starts at a different position + * based on the startingIndex in the attacking group. + * + * @param {Array} targets - Array of target entity IDs to attack (pre-sorted by distance) + * @param {number} startingIndex - This unit's position in the attacking group (0-based) + * @param {number} unitCount - Total number of units in the attacking group + * @param {boolean} allowCapture - Whether capturing is allowed + * @param {boolean} queued - If true, add to end of queue instead of replacing current order + * @param {boolean} pushFront - If true, add to front of queue (interrupt current order) + */ +UnitAI.prototype.AttackGroup = function(targets, startingIndex, unitCount, allowCapture = this.DEFAULT_CAPTURE, queued = false, pushFront = false) +{ + if (!targets?.length) + return; + + // Check if adding these targets would exceed the limit + const currentTotal = this.CountTotalTargetsInQueue(); + const newTargetCount = targets.length; + + if (currentTotal + newTargetCount > this.MAX_TOTAL_TARGETS) + { + // Trim the targets to fit within the limit + const allowedNewTargets = Math.max(0, this.MAX_TOTAL_TARGETS - currentTotal); + if (allowedNewTargets === 0) + return; + + // Trim the target array + targets = targets.slice(0, allowedNewTargets); + + // If trimming results in no targets, just return + if (targets.length === 0) + return; + } + + // If this unit is in a formation, move the formation controller out of the world + // This prevents the controller from staying at the coordinates where the units received the order + // That can potentially be far apart from where the fighting will occur. + // Result: The formation will reform into a more logical position once members are done (idle). + this.MoveFormationControllerOutOfWorld(); + + const stride = targets.length / unitCount; + const startIdx = Math.min(Math.round((startingIndex + 0.5) * stride), targets.length - 1); + + const order = { + "force": true, + "allowCapture": allowCapture, + "targetArray": targets.slice(), + "target": INVALID_ENTITY, + "currentTargetIndex": startIdx, + "originalFirstTarget": targets[0], + }; + + this.RememberTargetPosition(order); + this.AddOrder("Attack", order, queued, pushFront); +}; + +UnitAI.prototype.MoveFormationControllerOutOfWorld = function() +{ + if (!this.IsFormationMember() || !this.formationController) + return; + + const cmpControllerPosition = Engine.QueryInterface(this.formationController, IID_Position); + if (cmpControllerPosition?.IsInWorld()) + cmpControllerPosition.MoveOutOfWorld(); +}; + /** * Adds garrison order to the queue, forced by the player. */ @@ -6726,6 +7007,72 @@ UnitAI.prototype.AttackEntitiesByPreference = function(ents) return this.RespondToTargetedEntities(entsWithoutPref); }; +UnitAI.prototype.FindAndSetNextTarget = function(data) +{ + if (!this.EnsureValidTargetIndex(data)) + return false; + + const startIdx = data.currentTargetIndex; + const currentTarget = data.targetArray[startIdx]; + + // First check the starting target + if (this.CanAttack(currentTarget) && this.CheckTargetVisible(currentTarget)) + return this.SetNextTarget(data, startIdx); + + // Search forward using find + const forwardFound = data.targetArray + .slice(startIdx + 1) + .findIndex((target, i) => + this.CanAttack(target) && this.CheckTargetVisible(target) + ); + + if (forwardFound !== -1) + return this.SetNextTarget(data, startIdx + 1 + forwardFound); + + // Search backward using find + const backwardFound = data.targetArray + .slice(0, startIdx) + .reverse() + .findIndex((target, i) => + this.CanAttack(target) && this.CheckTargetVisible(target) + ); + + if (backwardFound !== -1) + { + // Convert reverse index back to original index + const originalIndex = startIdx - 1 - backwardFound; + return this.SetNextTarget(data, originalIndex); + } + + return false; +}; + +UnitAI.prototype.SetNextTarget = function(data, index) +{ + const target = data.targetArray[index]; + data.target = target; + data.attackType = this.GetBestAttackAgainst(target, data.allowCapture); + data.currentTargetIndex = index; + return true; +}; + +UnitAI.prototype.EnsureValidTargetIndex = function(data) +{ + if (!data.targetArray || data.currentTargetIndex === undefined) + return false; + + // If array is empty, clear the index + if (data.targetArray.length === 0) + { + data.currentTargetIndex = undefined; + return false; + } + + data.currentTargetIndex = Math.clamp(data.currentTargetIndex, 0, data.targetArray.length - 1); + + return true; +}; + /** * Call UnitAI.funcname(args) on all formation members. * @param resetFinishedEntities - If true, call ResetFinishedEntities first. @@ -6837,6 +7184,50 @@ UnitAI.prototype.ResetObstructionMitigationFlag = function() delete this.obstructionMitigationAttempted; }; +/** + * Counts the total number of target entities across all queued orders. + * This includes targetArray from group attacks and single targets. + * Each target reference is counted separately, even if the same entity + * appears in multiple orders or in both targetArray and target. + * @returns {number} - Total number of target entity references in the order queue + */ +UnitAI.prototype.CountTotalTargetsInQueue = function() +{ + let total = 0; + for (const order of this.orderQueue) + { + if (order.data) + { + // Count group attack targets + if (order.data.targetArray && Array.isArray(order.data.targetArray)) + total += order.data.targetArray.length; + + // Add single target for consistency + if (order.data.target && order.data.target !== INVALID_ENTITY) + total++; + } + } + return total; +}; + +UnitAI.prototype.CleanupGroupAttackState = function(order) +{ + if (order?.data?.targetArray) + { + delete order.data.targetArray; + delete order.data.currentTargetIndex; + } +}; + +UnitAI.prototype.CleanupAllGroupAttackStates = function() +{ + for (const order of this.orderQueue) + this.CleanupGroupAttackState(order); +}; + +// Maximum number of target entities allowed across all queued orders +UnitAI.prototype.MAX_TOTAL_TARGETS = 1000; + UnitAI.prototype.UnitFsm = new FSM(UnitAI.prototype.UnitFsmSpec); Engine.RegisterComponentType(IID_UnitAI, "UnitAI", UnitAI); diff --git a/binaries/data/mods/public/simulation/components/tests/test_UnitAI.js b/binaries/data/mods/public/simulation/components/tests/test_UnitAI.js index f6d1fa2e6e..629ce933c3 100644 --- a/binaries/data/mods/public/simulation/components/tests/test_UnitAI.js +++ b/binaries/data/mods/public/simulation/components/tests/test_UnitAI.js @@ -138,6 +138,7 @@ function TestFormationExiting(mode) "ResetActiveQuery": function(id) { if (mode == 0) return []; return [enemy]; }, "DisableActiveQuery": function(id) { }, "GetEntityFlagMask": function(identifier) { }, + "GetLosVisibility": (ent, player) => "visible" }); AddMock(SYSTEM_ENTITY, IID_TemplateManager, { @@ -319,8 +320,11 @@ function TestMoveIntoFormationWhileAttacking() "ResetActiveQuery": function(id) { return [enemy]; }, "DisableActiveQuery": function(id) { }, "GetEntityFlagMask": function(identifier) { }, + "GetLosVisibility": (target, player) => "visible", + "GetLosVisibilityPosition": (x, z, player) => "visible" }); + AddMock(SYSTEM_ENTITY, IID_TemplateManager, { "GetCurrentTemplateName": function(ent) { return "special/formations/line_closed"; }, }); @@ -406,6 +410,10 @@ function TestMoveIntoFormationWhileAttacking() "GetHitpoints": function() { return 40; }, }); + AddMock(enemy, IID_Ownership, { + "GetOwner": () => 2 // Different player ID (enemy) + }); + const controllerFormation = ConstructComponent(controller, "Formation", { "FormationShape": "square", "ShiftRows": "false", @@ -421,6 +429,10 @@ function TestMoveIntoFormationWhileAttacking() "DefaultStance": "aggressive" }); + AddMock(controller, IID_Ownership, { + "GetOwner": () => 1 + }); + AddMock(controller, IID_Position, { "GetTurretParent": () => INVALID_ENTITY, "JumpTo": function(x, z) { this.x = x; this.z = z; }, @@ -458,7 +470,7 @@ function TestMoveIntoFormationWhileAttacking() controllerFormation.SetMembers(units); - controllerAI.Attack(enemy, []); + controllerAI.Attack(enemy); for (const ent of unitAIs) TS_ASSERT_EQUALS(unitAI.fsmStateName, "INDIVIDUAL.COMBAT.ATTACKING"); diff --git a/binaries/data/mods/public/simulation/helpers/Commands.js b/binaries/data/mods/public/simulation/helpers/Commands.js index 35fbd1847a..294b1497c6 100644 --- a/binaries/data/mods/public/simulation/helpers/Commands.js +++ b/binaries/data/mods/public/simulation/helpers/Commands.js @@ -179,6 +179,28 @@ var g_Commands = { }); }, + "attack-group": function(player, cmd, data) + { + const unitAIs = data.entities.flatMap(ent => + { + const cmpUnitAI = Engine.QueryInterface(ent, IID_UnitAI); + const cmpAttack = Engine.QueryInterface(ent, IID_Attack); + + // Check if unit can move and has attack capability + const canAttack = cmpAttack && cmpAttack.GetAttackTypes().length > 0; + return (cmpUnitAI && cmpUnitAI.AbleToMove() && canAttack) ? [cmpUnitAI] : []; + }); + + if (!unitAIs.length || !cmd.targets?.length) + return; + + // Pass the entire target array and unit count to each UnitAI + unitAIs.forEach((cmpUnitAI, index) => + { + cmpUnitAI.AttackGroup(cmd.targets, index, unitAIs.length, cmd.allowCapture, cmd.queued, cmd.pushFront); + }); + }, + "patrol": function(player, cmd, data) { const ents = data.entities.length;