mirror of
https://gitea.wildfiregames.com/0ad/0ad
synced 2026-06-16 05:13:58 -07:00
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:
parent
adab416847
commit
e1c4e3bd42
4 changed files with 623 additions and 23 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue