1
0
forked from mirrors/0ad

Simplify calls for moving units into formation

Co-authored-by: @Vantha

Centralize all calls to move units into formation in UnitAI
Allowing additional checks to be done
before calling members to reform formation

Fixes : #8545

Revised Formation methods and when they do call
on reforming formation

Fixes : #7328

Don't order formation members to go to a point
if the controller is stuck
Instead make the controller jump to the member
who is closest to the destination

Fixes : #8543

Correct 'filter(member)' instead of 'filter(ent)'
in GetClosestMemberToPosition (ex GetClosestMember)

Fixes :	#5120
This commit is contained in:
Atrik
2025-11-17 15:20:12 +01:00
committed by Vantha
parent cfb742c914
commit 06c40946eb
2 changed files with 344 additions and 198 deletions
@@ -90,9 +90,7 @@ Formation.prototype.variablesToSerialize = [
"maxRowsUsed",
"maxColumnsUsed",
"finishedEntities",
"idleEntities",
"columnar",
"rearrange",
"formationMembersWithAura",
"width",
"depth",
@@ -146,11 +144,8 @@ Formation.prototype.Init = function(deserialized = false)
this.maxColumnsUsed = [];
// Entities that have finished the original task.
this.finishedEntities = new Set();
this.idleEntities = new Set();
// Whether we're travelling in column (vs box) formation.
this.columnar = false;
// Whether we should rearrange all formation members.
this.rearrange = true;
// Members with a formation aura.
this.formationMembersWithAura = [];
this.width = 0;
@@ -163,7 +158,7 @@ Formation.prototype.Init = function(deserialized = false)
return;
Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer)
.SetInterval(this.entity, IID_Formation, "ShapeUpdate", 1000, 1000, null);
.SetInterval(this.entity, IID_Formation, "UpdateTwinFormationsForMerge", 1000, 1000, null);
};
Formation.prototype.Serialize = function()
@@ -210,26 +205,33 @@ Formation.prototype.GetMembers = function()
return this.members;
};
Formation.prototype.GetClosestMember = function(ent, filter)
/**
* Finds the closest formation member to a given position.
*
* @param {Vector2D} position - The 2D position to find the closest formation member to
* @param {function} [filter] - Optional filter function that takes an entity ID and
* returns true if the entity should be considered
* @returns {number} Entity ID of the closest formation member, or INVALID_ENTITY
* if no suitable member is found
*/
Formation.prototype.GetClosestMemberToPosition = function(targetPosition, filter)
{
const cmpEntPosition = Engine.QueryInterface(ent, IID_Position);
if (!cmpEntPosition || !cmpEntPosition.IsInWorld())
return INVALID_ENTITY;
const entPosition = cmpEntPosition.GetPosition2D();
let closestMember = INVALID_ENTITY;
let closestDistance = Infinity;
for (const member of this.members)
{
if (filter && !filter(ent))
if (filter && !filter(member))
continue;
const cmpPosition = Engine.QueryInterface(member, IID_Position);
if (!cmpPosition || !cmpPosition.IsInWorld())
const cmpMemberPosition = Engine.QueryInterface(member, IID_Position);
if (!cmpMemberPosition || !cmpMemberPosition.IsInWorld())
continue;
const pos = cmpPosition.GetPosition2D();
const dist = entPosition.distanceToSquared(pos);
const memberPos = cmpMemberPosition.GetPosition2D();
const memberPositionVector = new Vector2D(memberPos.x, memberPos.y);
const targetPositionVector = new Vector2D(targetPosition.x, targetPosition.y);
const dist = targetPositionVector.distanceToSquared(memberPositionVector);
if (dist < closestDistance)
{
closestMember = member;
@@ -239,6 +241,25 @@ Formation.prototype.GetClosestMember = function(ent, filter)
return closestMember;
};
/**
* Finds the closest formation member to a given entity.
*
* @param {number} ent - The entity ID to find the closest formation member to
* @param {function} [filter] - Optional filter function that takes an entity ID and
* returns true if the entity should be considered
* @returns {number} Entity ID of the closest formation member, or INVALID_ENTITY
* if no suitable member is found or the reference entity is invalid
*/
Formation.prototype.GetClosestMemberToEntity = function(ent, filter)
{
const cmpEntPosition = Engine.QueryInterface(ent, IID_Position);
if (!cmpEntPosition || !cmpEntPosition.IsInWorld())
return INVALID_ENTITY;
const entPosition = cmpEntPosition.GetPosition2D();
return this.GetClosestMemberToPosition(entPosition, filter);
};
/**
* Returns the 'primary' member of this formation (typically the most
* important unit type), for e.g. playing a representative sound.
@@ -320,34 +341,6 @@ Formation.prototype.AreAllMembersFinished = function()
return this.finishedEntities.size === this.members.length;
};
Formation.prototype.SetIdleEntity = function(ent)
{
this.idleEntities.add(ent);
};
Formation.prototype.UnsetIdleEntity = function(ent)
{
this.idleEntities.delete(ent);
};
Formation.prototype.ResetIdleEntities = function()
{
this.idleEntities.clear();
};
Formation.prototype.AreAllMembersIdle = function()
{
return this.idleEntities.size === this.members.length;
};
/**
* Set whether we are allowed to rearrange formation members.
*/
Formation.prototype.SetRearrange = function(rearrange)
{
this.rearrange = rearrange;
};
/**
* Initialize the members of this formation.
* Must only be called once.
@@ -379,10 +372,16 @@ Formation.prototype.SetMembers = function(ents)
};
/**
* Remove the given list of entities.
* Removes entities from the formation member list.
* The entities must already be members of this formation.
*
* Formation geometry and member positions are not recalculated.
* As we let UnitAI define proper times and conditions to do so.
* This avoids reordering in situations we don't want it to happen like in combat.
*
* @param {Array} ents - Array of entity IDs to remove from the formation
* @param {boolean} rename - Whether the removal was part of an entity rename
(prevents disbanding of the formation when under the member limit).
* (prevents disbanding when under the member limit)
*/
Formation.prototype.RemoveMembers = function(ents, renamed = false)
{
@@ -412,23 +411,31 @@ Formation.prototype.RemoveMembers = function(ents, renamed = false)
this.formationMembersWithAura = this.formationMembersWithAura.filter(ent => !ents.includes(ent));
if (renamed)
return;
// If there's nobody left, destroy the formation
// unless this is a rename where we can have 0 members temporarily.
if (!renamed && this.members.length < +this.template.RequiredMemberCount)
if (this.members.length < +this.template.RequiredMemberCount)
{
this.Disband();
return;
}
this.ComputeMotionParameters();
if (!this.rearrange)
return;
// Rearrange the remaining members.
this.MoveMembersIntoFormation(true, true, this.lastOrderVariant);
};
/**
* Adds entities to the formation member list.
*
* Formation geometry and member positions are not recalculated.
* As we let UnitAI define proper times and conditions to do so.
* This avoids reordering in situations we don't want it to happen like in combat.
*
* @param {Array} ents - Array of entity IDs to add to the formation
*
* @see ArrangeFormation - To update formation layout after adding members
*/
Formation.prototype.AddMembers = function(ents)
{
this.offsets = undefined;
@@ -457,11 +464,6 @@ Formation.prototype.AddMembers = function(ents)
}
this.ComputeMotionParameters();
if (!this.rearrange)
return;
this.MoveMembersIntoFormation(true, true, this.lastOrderVariant);
};
/**
@@ -487,7 +489,7 @@ Formation.prototype.Disband = function()
* otherwise the order to walk into formation is just pushed to the front.
* @param {string | undefined} variant - Variant to be passed as order parameter.
*/
Formation.prototype.MoveMembersIntoFormation = function(moveCenter, force, variant)
Formation.prototype.ArrangeFormation = function(moveCenter, force, variant)
{
if (!this.members.length)
return;
@@ -931,21 +933,41 @@ Formation.prototype.ComputeMotionParameters = function()
cmpUnitMotion.SetPassabilityClassName(maxPassClass);
};
Formation.prototype.ShapeUpdate = function()
Formation.prototype.UpdateTwinFormationsForMerge = function()
{
if (!this.rearrange)
const cmpUnitAI = Engine.QueryInterface(this.entity, IID_UnitAI);
// If one of the formations is idle, we don't want to merge into that one.
// Because the formation controller should keep moving towards the destination
// instead of staying at middle point.
if (!this.IsRearrangementAllowed() || cmpUnitAI.isIdle)
return;
const thisSize = this.GetSize();
const thisMaxHalf = Math.max(thisSize.width, thisSize.depth) / 2;
const baseDistance = thisMaxHalf + this.formationSeparation;
// Check the distance to twin formations, and merge if
// the formations could collide.
for (let i = this.twinFormations.length - 1; i >= 0; --i)
{
// Only do the check on one side.
if (this.twinFormations[i] <= this.entity)
const otherFormationID = this.twinFormations[i];
// Skip invalid entities and self
if (otherFormationID == INVALID_ENTITY || otherFormationID == this.entity)
continue;
const otherFormationAI = Engine.QueryInterface(otherFormationID, IID_UnitAI);
// If both formations aren't idle, we can do the distance check on only one side,
// so skip one of them.
if (!otherFormationAI.isIdle && otherFormationID <= this.entity)
continue;
const cmpPosition = Engine.QueryInterface(this.entity, IID_Position);
const cmpOtherPosition = Engine.QueryInterface(this.twinFormations[i], IID_Position);
const cmpOtherFormation = Engine.QueryInterface(this.twinFormations[i], IID_Formation);
const cmpOtherPosition = Engine.QueryInterface(otherFormationID, IID_Position);
const cmpOtherFormation = Engine.QueryInterface(otherFormationID, IID_Formation);
if (!cmpPosition || !cmpOtherPosition || !cmpOtherFormation ||
!cmpPosition.IsInWorld() || !cmpOtherPosition.IsInWorld())
continue;
@@ -953,36 +975,27 @@ Formation.prototype.ShapeUpdate = function()
const thisPosition = cmpPosition.GetPosition2D();
const otherPosition = cmpOtherPosition.GetPosition2D();
const dx = thisPosition.x - otherPosition.x;
const dy = thisPosition.y - otherPosition.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const dist = thisPosition.distanceTo(otherPosition);
const thisSize = this.GetSize();
const otherSize = cmpOtherFormation.GetSize();
const minDist = Math.max(thisSize.width / 2, thisSize.depth / 2) +
Math.max(otherSize.width / 2, otherSize.depth / 2) +
this.formationSeparation;
const minDist = baseDistance + Math.max(otherSize.width, otherSize.depth) / 2;
if (minDist < dist)
continue;
// Merge the members from the twin formation into this one
// twin formations should always have exactly the same orders.
// Merge the members from the other formation into this one
const otherMembers = cmpOtherFormation.members;
cmpOtherFormation.RemoveMembers(otherMembers);
this.AddMembers(otherMembers);
}
// Switch between column and box if necessary.
const cmpUnitAI = Engine.QueryInterface(this.entity, IID_UnitAI);
const walkingDistance = cmpUnitAI.ComputeWalkingDistance();
const columnar = walkingDistance > g_ColumnDistanceThreshold;
if (columnar != this.columnar)
{
this.offsets = undefined;
this.columnar = columnar;
// Disable moveCenter so we can't get stuck in a loop of switching
// shape causing center to change causing shape to switch back.
this.MoveMembersIntoFormation(false, true, this.lastOrderVariant);
// We add members from the other formation to this one.
// The other formation will get disbanded for having no members
this.AddMembers(otherMembers, true);
// Remove the merged formation from twin formations list
this.twinFormations.splice(i, 1);
// Update Formation and members position
this.UpdateFormation(true, true);
}
};
@@ -1009,16 +1022,14 @@ Formation.prototype.OnGlobalEntityRenamed = function(msg)
if (this.finishedEntities.delete(msg.entity))
this.finishedEntities.add(msg.newentity);
// Save rearranging to temporarily set it to false.
const temp = this.rearrange;
this.rearrange = false;
// First remove the old member to be able to reuse its position.
this.RemoveMembers([msg.entity], true);
this.AddMembers([msg.newentity]);
this.memberPositions[msg.newentity] = this.memberPositions[msg.entity];
this.rearrange = temp;
// Update Formation
// to make sure added (renamed) members will move with the controller if applicable.
this.UpdateFormation(false, false);
};
Formation.prototype.RegisterTwinFormation = function(entity)
@@ -1047,6 +1058,98 @@ Formation.prototype.LoadFormation = function(newTemplate)
return Engine.QueryInterface(newFormation, IID_UnitAI);
};
/**
* Updates formation members' positions based on current state.
* Moves members into appropriate formation type if rearrangement is allowed.
*
* @param {boolean} moveCenter - Whether to move the formation center
* @param {boolean} force - Force rearrangement regardless of state
* @param {string} formationType - Formation variant to be passed as order parameter.
*/
Formation.prototype.UpdateFormation = function(moveCenter = false, force = false, formationType = "default") {
// Move members into appropriate formation type
if (this.IsRearrangementAllowed() || force)
this.ArrangeFormation(moveCenter, force, formationType);
};
/**
* Checks if the formation should rearrange based on controller and member states.
* Prevents rearrangement during critical combat or activity states.
*
* @returns {boolean} True if formation should rearrange, false otherwise
*/
Formation.prototype.IsRearrangementAllowed = function()
{
if (this.IsControllerBlockingRearrangement())
return false;
return !this.AreMembersBlockingRearrangement();
};
/**
* Checks if the formation controller is in a state that prevents rearrangement.
*
* @returns {boolean} True if controller is in a no-rearrange state
*/
Formation.prototype.IsControllerBlockingRearrangement = function()
{
const cmpControllerAI = Engine.QueryInterface(this.entity, IID_UnitAI);
const noRearrangeStates = [
"COMBAT.ATTACKING"
];
const state = cmpControllerAI.GetCurrentState();
return noRearrangeStates.some(noState => state.includes(noState));
};
/**
* Checks if any formation members are in critical states that shouldn't be interrupted.
* Uses a threshold to allow rearrangement when only a small percentage are busy.
*
* @returns {boolean} True if significant number of members are in critical states
*/
Formation.prototype.AreMembersBlockingRearrangement = function()
{
const criticalStates = new Set([
"COMBAT.ATTACKING",
"COMBAT.CHASING",
"COMBAT.APPROACHING",
"HEAL.HEALING",
"GATHER",
"REPAIR"
]);
let totalMembers = 0;
let criticalMembers = 0;
for (const member of this.members)
{
const cmpMemberAI = Engine.QueryInterface(member, IID_UnitAI);
if (!cmpMemberAI)
continue;
totalMembers++;
const state = cmpMemberAI.GetCurrentState();
for (const criticalState of criticalStates)
{
if (state.includes(criticalState))
{
criticalMembers++;
break;
}
}
}
// If no valid members, return false
if (totalMembers === 0)
return false;
// Return true if more than 5% are in critical states.
// Cover edge cases where a few members would be stuck
// somewhere and still fighting for example.
return (criticalMembers / totalMembers) > 0.05;
};
Formation.prototype.OnEntityRenamed = function(msg)
{
@@ -297,13 +297,7 @@ UnitAI.prototype.UnitFsmSpec = {
const cmpFormation = Engine.QueryInterface(this.formationController, IID_Formation);
if (cmpFormation)
{
cmpFormation.SetRearrange(false);
// Triggers FormationLeave, which ultimately will FinishOrder,
// discarding this order.
cmpFormation.RemoveMembers([this.entity]);
cmpFormation.SetRearrange(true);
}
return ACCEPT_ORDER;
},
@@ -772,12 +766,13 @@ UnitAI.prototype.UnitFsmSpec = {
},
"Order.Attack": function(msg) {
let target = msg.data.target;
const target = msg.data.target;
let formationTarget;
const cmpTargetUnitAI = Engine.QueryInterface(target, IID_UnitAI);
if (cmpTargetUnitAI && cmpTargetUnitAI.IsFormationMember())
target = cmpTargetUnitAI.GetFormationController();
formationTarget = cmpTargetUnitAI.GetFormationController();
if (!this.CheckFormationTargetAttackRange(target))
if (!this.CheckFormationTargetAttackRange(formationTarget || target))
{
if (this.AbleToMove() && this.CheckTargetVisible(target))
{
@@ -786,7 +781,8 @@ UnitAI.prototype.UnitFsmSpec = {
}
return this.FinishOrder();
}
this.CallMemberFunction("Attack", [target, msg.data.allowCapture, false]);
this.CallMemberFunction("Attack", [formationTarget || target, msg.data.allowCapture, false]);
const cmpAttack = Engine.QueryInterface(this.entity, IID_Attack);
if (cmpAttack && cmpAttack.CanAttackAsFormation())
this.SetNextState("COMBAT.ATTACKING");
@@ -997,12 +993,6 @@ UnitAI.prototype.UnitFsmSpec = {
"IDLE": {
"enter": function(msg) {
// Turn rearrange off. Otherwise, if the formation is idle
// but individual units go off to fight,
// any death will rearrange the formation, which looks odd.
// Instead, move idle units in formation on a timer.
const cmpFormation = Engine.QueryInterface(this.entity, IID_Formation);
cmpFormation.SetRearrange(false);
// Start the timer on the next turn to catch up with potential stragglers.
this.StartTimer(100, 2000);
this.isIdle = true;
@@ -1020,17 +1010,16 @@ UnitAI.prototype.UnitFsmSpec = {
if (!cmpFormation)
return;
if (this.TestAllMemberFunction("IsIdle"))
cmpFormation.MoveMembersIntoFormation(false, false);
this.RequestFormationUpdate();
},
},
"WALKING": {
"enter": function() {
const cmpFormation = Engine.QueryInterface(this.entity, IID_Formation);
cmpFormation.SetRearrange(true);
cmpFormation.MoveMembersIntoFormation(true, true);
this.RequestFormationUpdate(true, true);
this.StartTimer(100, 2000);
if (!this.MoveTo(this.order.data))
{
this.FinishOrder();
@@ -1045,32 +1034,20 @@ UnitAI.prototype.UnitFsmSpec = {
},
"MovementUpdate": function(msg) {
if (msg.veryObstructed && !this.timer)
{
// It's possible that the controller (with large clearance)
// is stuck, but not the individual units.
// Ask them to move individually for a little while.
this.CallMemberFunction("MoveTo", [this.order.data]);
this.StartTimer(3000);
return;
}
else if (this.timer)
return;
if (msg.likelyFailure || this.CheckRange(this.order.data))
if (msg.veryObstructed && !this.obstructionMitigationAttempted)
this.AttemptObstructionMitigation();
else if (msg.likelyFailure || this.CheckRange(this.order.data))
this.FinishOrder();
},
"Timer": function() {
// Reenter to reset the pathfinder state.
this.SetNextState("WALKING");
// Update Formation in case some members left.
this.RequestFormationUpdate(false, true);
}
},
"WALKINGANDFIGHTING": {
"enter": function(msg) {
const cmpFormation = Engine.QueryInterface(this.entity, IID_Formation);
cmpFormation.SetRearrange(true);
cmpFormation.MoveMembersIntoFormation(true, true, "combat");
this.RequestFormationUpdate(true, true, "combat");
if (!this.MoveTo(this.order.data))
{
this.FinishOrder();
@@ -1095,7 +1072,9 @@ UnitAI.prototype.UnitFsmSpec = {
},
"MovementUpdate": function(msg) {
if (msg.likelyFailure || this.CheckRange(this.order.data))
if (msg.veryObstructed && !this.obstructionMitigationAttempted)
this.AttemptObstructionMitigation();
else if (msg.likelyFailure || this.CheckRange(this.order.data))
this.FinishOrder();
},
},
@@ -1128,10 +1107,6 @@ UnitAI.prototype.UnitFsmSpec = {
"PATROLLING": {
"enter": function() {
const cmpFormation = Engine.QueryInterface(this.entity, IID_Formation);
cmpFormation.SetRearrange(true);
cmpFormation.MoveMembersIntoFormation(true, true, "combat");
const cmpPosition = Engine.QueryInterface(this.entity, IID_Position);
if (!cmpPosition || !cmpPosition.IsInWorld() ||
!this.MoveTo(this.order.data))
@@ -1151,11 +1126,22 @@ UnitAI.prototype.UnitFsmSpec = {
},
"Timer": function(msg) {
// Force the units to stick together
// TODO : better handling of formation patrolling
// See https://gitea.wildfiregames.com/0ad/0ad/issues/8558
this.RequestFormationUpdate(true, true, "combat");
if (this.FindWalkAndFightTargets())
this.SetNextState("MEMBER");
},
"MovementUpdate": function(msg) {
if (msg.veryObstructed && !this.obstructionMitigationAttempted)
{
this.AttemptObstructionMitigation();
return;
}
if (!msg.likelyFailure && !msg.likelySuccess && !this.RelaxedMaxRangeCheck(this.order.data, this.DefaultRelaxedMaxRange))
return;
@@ -1202,10 +1188,7 @@ UnitAI.prototype.UnitFsmSpec = {
this.FinishOrder();
return true;
}
const cmpFormation = Engine.QueryInterface(this.entity, IID_Formation);
cmpFormation.SetRearrange(true);
cmpFormation.MoveMembersIntoFormation(true, true);
this.RequestFormationUpdate(true, true);
// If the holder should pickup, warn it so it can take needed action.
const cmpHolder = Engine.QueryInterface(this.order.data.target, this.order.data.garrison ? IID_GarrisonHolder : IID_TurretHolder);
@@ -1245,9 +1228,7 @@ UnitAI.prototype.UnitFsmSpec = {
"FORMING": {
"enter": function() {
const cmpFormation = Engine.QueryInterface(this.entity, IID_Formation);
cmpFormation.SetRearrange(true);
cmpFormation.MoveMembersIntoFormation(true, true);
this.RequestFormationUpdate(true, true);
if (!this.MoveTo(this.order.data))
{
@@ -1272,9 +1253,7 @@ UnitAI.prototype.UnitFsmSpec = {
"COMBAT": {
"APPROACHING": {
"enter": function() {
const cmpFormation = Engine.QueryInterface(this.entity, IID_Formation);
cmpFormation.SetRearrange(true);
cmpFormation.MoveMembersIntoFormation(true, true, "combat");
this.RequestFormationUpdate(true, true, "combat");
if (!this.MoveFormationToTargetAttackRange(this.order.data.target))
{
@@ -1316,11 +1295,7 @@ UnitAI.prototype.UnitFsmSpec = {
this.FinishOrder();
return true;
}
const cmpFormation = Engine.QueryInterface(this.entity, IID_Formation);
// TODO fix the rearranging while attacking as formation
cmpFormation.SetRearrange(!this.IsAttackingAsFormation());
cmpFormation.MoveMembersIntoFormation(false, false, "combat");
this.RequestFormationUpdate(false, false, "combat");
this.StartTimer(200, 200);
return false;
},
@@ -1341,9 +1316,6 @@ UnitAI.prototype.UnitFsmSpec = {
"leave": function(msg) {
this.StopTimer();
var cmpFormation = Engine.QueryInterface(this.entity, IID_Formation);
if (cmpFormation)
cmpFormation.SetRearrange(true);
},
},
},
@@ -1362,11 +1334,6 @@ UnitAI.prototype.UnitFsmSpec = {
},
"enter": function(msg) {
// Don't rearrange the formation, as that forces all units to stop
// what they're doing.
const cmpFormation = Engine.QueryInterface(this.entity, IID_Formation);
if (cmpFormation)
cmpFormation.SetRearrange(false);
// While waiting on members, the formation is more like
// a group of unit and does not have a well-defined position,
// so move the controller out of the world to enforce that.
@@ -1391,10 +1358,6 @@ UnitAI.prototype.UnitFsmSpec = {
"leave": function(msg) {
this.StopTimer();
// Reform entirely as members might be all over the place now.
const cmpFormation = Engine.QueryInterface(this.entity, IID_Formation);
if (cmpFormation && (cmpFormation.AreAllMembersIdle() || this.orderQueue.length))
cmpFormation.MoveMembersIntoFormation(true);
// Update the held position so entities respond to orders.
const cmpPosition = Engine.QueryInterface(this.entity, IID_Position);
@@ -1411,6 +1374,7 @@ UnitAI.prototype.UnitFsmSpec = {
// States for entities moving as part of a formation:
"FORMATIONMEMBER": {
"FormationLeave": function(msg) {
// Called when selecting units out of a formation
// Stop moving as soon as the formation disbands
// Keep current rotation
const facePointAfterMove = this.GetFacePointAfterMove();
@@ -1611,7 +1575,6 @@ UnitAI.prototype.UnitFsmSpec = {
const cmpFormationAI = Engine.QueryInterface(this.formationController, IID_UnitAI);
if (!cmpFormationAI || !cmpFormationAI.IsIdle())
return;
Engine.QueryInterface(this.formationController, IID_Formation).SetIdleEntity(this.entity);
}
this.isIdle = true;
@@ -1651,8 +1614,6 @@ UnitAI.prototype.UnitFsmSpec = {
if (this.isIdle)
{
if (this.IsFormationMember())
Engine.QueryInterface(this.formationController, IID_Formation).UnsetIdleEntity(this.entity);
this.isIdle = false;
Engine.PostMessage(this.entity, MT_UnitIdleChanged, { "idle": this.isIdle });
}
@@ -1721,7 +1682,6 @@ UnitAI.prototype.UnitFsmSpec = {
const cmpFormationAI = Engine.QueryInterface(this.formationController, IID_UnitAI);
if (!cmpFormationAI || !cmpFormationAI.IsIdle())
return;
Engine.QueryInterface(this.formationController, IID_Formation).SetIdleEntity(this.entity);
}
this.isIdle = true;
@@ -2079,6 +2039,11 @@ UnitAI.prototype.UnitFsmSpec = {
"APPROACHING": {
"enter": function() {
// Set this.order.data.target and this.order.formationTarget.
// Should only mutate these once, when receiving a order with
// a formation controller as target.
this.HandleTargetAsFormation();
if (!this.MoveToTargetAttackRange(this.order.data.target, this.order.data.attackType))
{
this.FinishOrder();
@@ -2159,14 +2124,10 @@ UnitAI.prototype.UnitFsmSpec = {
"ATTACKING": {
"enter": function() {
let target = this.order.data.target;
const cmpFormation = Engine.QueryInterface(target, IID_Formation);
if (cmpFormation)
{
this.order.data.formationTarget = target;
target = cmpFormation.GetClosestMember(this.entity);
this.order.data.target = target;
}
// Set this.order.data.target and this.order.formationTarget.
// Should only mutate these once, when receiving a order with
// a formation controller as target.
this.HandleTargetAsFormation();
this.shouldCheer = false;
@@ -2177,7 +2138,7 @@ UnitAI.prototype.UnitFsmSpec = {
return true;
}
if (!this.CheckTargetAttackRange(target, this.order.data.attackType))
if (!this.CheckTargetAttackRange(this.order.data.target, this.order.data.attackType))
{
if (this.CanPack())
{
@@ -2263,18 +2224,25 @@ UnitAI.prototype.UnitFsmSpec = {
},
"enter": function() {
// Try to find the formation the target was a part of.
// Check if we are attacking a formation.
let cmpFormation = Engine.QueryInterface(this.order.data.target, IID_Formation);
if (!cmpFormation)
if (cmpFormation)
this.order.data.formationTarget = this.order.data.target;
else
cmpFormation = Engine.QueryInterface(this.order.data.formationTarget || INVALID_ENTITY, IID_Formation);
// If the target is a formation, pick closest member.
if (cmpFormation)
{
const filter = (t) => this.CanAttack(t);
this.order.data.formationTarget = this.order.data.target;
const target = cmpFormation.GetClosestMember(this.entity, filter);
this.order.data.target = target;
// As we're also setting this.order.data.target, if GetClosestMemberToEntity returns INVALID_ENTITY, this should prevent us from reentering
// here again right away, as cmpFormation would be null.
// Now we cannot have an infinite loop if ATTACKING send us to FINDINGNEWTARGET again.
this.order.data.target = cmpFormation.GetClosestMemberToEntity(this.entity, filter);
if (this.order.data.target == INVALID_ENTITY)
delete this.order.data.formationTarget;
this.SetNextState("COMBAT.ATTACKING");
return true;
}
@@ -3315,12 +3283,7 @@ UnitAI.prototype.UnitFsmSpec = {
{
const cmpFormation = Engine.QueryInterface(this.formationController, IID_Formation);
if (cmpFormation)
{
const rearrange = cmpFormation.rearrange;
cmpFormation.SetRearrange(false);
cmpFormation.RemoveMembers([this.entity]);
cmpFormation.SetRearrange(rearrange);
}
}
const cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer);
@@ -4771,7 +4734,7 @@ UnitAI.prototype.MoveToTargetAttackRange = function(target, type)
const cmpFormation = Engine.QueryInterface(target, IID_Formation);
if (cmpFormation)
target = cmpFormation.GetClosestMember(this.entity);
target = cmpFormation.GetClosestMemberToEntity(this.entity);
if (type != "Ranged")
return this.MoveToTargetRange(target, IID_Attack, type);
@@ -4812,7 +4775,7 @@ UnitAI.prototype.MoveFormationToTargetAttackRange = function(target)
{
const cmpTargetFormation = Engine.QueryInterface(target, IID_Formation);
if (cmpTargetFormation)
target = cmpTargetFormation.GetClosestMember(this.entity);
target = cmpTargetFormation.GetClosestMemberToEntity(this.entity);
if (!this.CheckTargetVisible(target))
return false;
@@ -4826,6 +4789,18 @@ UnitAI.prototype.MoveFormationToTargetAttackRange = function(target)
return this.AbleToMove(cmpUnitMotion) && cmpUnitMotion.MoveToTargetRange(target, range.min, range.max);
};
/**
* Requests the formation component to update member positions.
* Called by formation controller when formation needs reorganization.
*/
UnitAI.prototype.RequestFormationUpdate = function(moveCenter = false, force = false, formationType = "default") {
const cmpFormation = Engine.QueryInterface(this.entity, IID_Formation);
if (!cmpFormation)
return;
cmpFormation.UpdateFormation(moveCenter, force, formationType);
};
/**
* Generic dispatcher for other Check...Range functions.
* @param iid - Interface ID (optional) implementing GetRange
@@ -4883,7 +4858,7 @@ UnitAI.prototype.CheckTargetAttackRange = function(target, type)
const cmpFormation = Engine.QueryInterface(target, IID_Formation);
if (cmpFormation)
target = cmpFormation.GetClosestMember(this.entity);
target = cmpFormation.GetClosestMemberToEntity(this.entity);
const cmpAttack = Engine.QueryInterface(this.entity, IID_Attack);
return cmpAttack && cmpAttack.IsTargetInRange(target, type);
@@ -4905,7 +4880,7 @@ UnitAI.prototype.CheckFormationTargetAttackRange = function(target)
{
const cmpTargetFormation = Engine.QueryInterface(target, IID_Formation);
if (cmpTargetFormation)
target = cmpTargetFormation.GetClosestMember(this.entity);
target = cmpTargetFormation.GetClosestMemberToEntity(this.entity);
const cmpFormationAttack = Engine.QueryInterface(this.entity, IID_Attack);
if (!cmpFormationAttack)
@@ -5202,6 +5177,23 @@ UnitAI.prototype.ShouldChaseTargetedEntity = function(target, force)
// External interface functions
/**
* Checks if the current target is a formation controller.
* If it is a formation controller, we identify the closest individual
* member within it and set this.order.data.target to it instead.
* The original formation controller ID is stored in this.order.data.formationTarget
* instead.
*/
UnitAI.prototype.HandleTargetAsFormation = function()
{
const cmpFormation = Engine.QueryInterface(this.order.data.target, IID_Formation);
if (!cmpFormation)
return;
this.order.data.formationTarget = this.order.data.target;
this.order.data.target = cmpFormation.GetClosestMemberToEntity(this.entity);
};
/**
* Order a unit to leave the formation it is in.
* Used to handle queued no-formation orders for units in formation.
@@ -6565,16 +6557,67 @@ UnitAI.prototype.CallPlayerOwnedEntitiesFunctionInRange = function(funcname, arg
};
/**
* Call obj.functname(args) on UnitAI components of all formation members,
* and return true if all calls return true.
* Attempts to mitigate formation obstruction by teleporting the formation controller
* to the member closest to the destination.
*
* This is used when the formation controller (with large clearance) is stuck on obstacles,
* but individual units can still navigate. The controller jumps to a member's position
* to bypass the obstruction.
*
* The mitigation have a cooldown to prevent any weird behaviour.
*/
UnitAI.prototype.TestAllMemberFunction = function(funcname, args)
UnitAI.prototype.AttemptObstructionMitigation = function()
{
if (this.obstructionMitigationAttempted)
return;
const cmpFormation = Engine.QueryInterface(this.entity, IID_Formation);
return cmpFormation && cmpFormation.GetMembers().every(ent => {
const cmpUnitAI = Engine.QueryInterface(ent, IID_UnitAI);
return cmpUnitAI[funcname].apply(cmpUnitAI, args);
});
const destination = this.order.data;
if (!cmpFormation || !destination || destination.x === undefined || destination.z === undefined)
return;
// Convert x,z coordinates to x,y coordinates for GetClosestMemberToPosition
const destinationVec = new Vector2D(destination.x, destination.z);
const closestMember = cmpFormation.GetClosestMemberToPosition(destinationVec);
if (closestMember == INVALID_ENTITY)
return;
const cmpMemberPosition = Engine.QueryInterface(closestMember, IID_Position);
const cmpControllerPosition = Engine.QueryInterface(this.entity, IID_Position);
if (!cmpMemberPosition || !cmpControllerPosition)
return;
const memberPosObj = cmpMemberPosition.GetPosition2D();
const controllerPosObj = cmpControllerPosition.GetPosition2D();
const memberPos = new Vector2D(memberPosObj.x, memberPosObj.y);
const controllerPos = new Vector2D(controllerPosObj.x, controllerPosObj.y);
// Check if member is more than 2 meters closer to destination than controller
if (memberPos.distanceTo(destinationVec) < controllerPos.distanceTo(destinationVec) - 2)
cmpControllerPosition.JumpTo(memberPos.x, memberPos.y);
this.SetObstructionMitigationFlag();
};
UnitAI.prototype.SetObstructionMitigationFlag = function()
{
this.obstructionMitigationAttempted = true;
// Clear existing timer if any
if (this.obstructionMitigationTimer !== undefined)
Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer)
.CancelTimer(this.obstructionMitigationTimer);
// Store the new timer ID
this.obstructionMitigationTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer)
.SetTimeout(this.entity, IID_UnitAI, "ResetObstructionMitigationFlag", 5000, {});
};
UnitAI.prototype.ResetObstructionMitigationFlag = function()
{
delete this.obstructionMitigationAttempted;
};
UnitAI.prototype.UnitFsm = new FSM(UnitAI.prototype.UnitFsmSpec);