diff --git a/binaries/data/mods/public/simulation/components/Formation.js b/binaries/data/mods/public/simulation/components/Formation.js index 0d50a95576..8fbaf9a30d 100644 --- a/binaries/data/mods/public/simulation/components/Formation.js +++ b/binaries/data/mods/public/simulation/components/Formation.js @@ -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) { diff --git a/binaries/data/mods/public/simulation/components/UnitAI.js b/binaries/data/mods/public/simulation/components/UnitAI.js index 3caa1ae557..c6e2d0a428 100644 --- a/binaries/data/mods/public/simulation/components/UnitAI.js +++ b/binaries/data/mods/public/simulation/components/UnitAI.js @@ -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);