Enable using SelectionBox for targeting

Introduce a new  input, a command handler and adapt
UnitAI to handle arrays of targets in a single order
This commit is contained in:
Atrik 2025-11-09 13:52:02 +01:00
parent adab416847
commit e1c4e3bd42
4 changed files with 623 additions and 23 deletions

View file

@ -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);
}

View file

@ -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);

View file

@ -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");

View file

@ -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;