function Formation() {}
Formation.prototype.Schema =
"" +
"" +
""+
"2"+
""+
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"" +
"";
// Distance under which the formation will not try to turn towards the target position.
var g_RotateDistanceThreshold = 1;
Formation.prototype.variablesToSerialize = [
"lastOrderVariant",
"members",
"memberPositions",
"maxRowsUsed",
"maxColumnsUsed",
"finishedEntities",
"formationMembersWithAura",
"width",
"depth",
"twinFormations",
"formationSeparation",
"offsets"
];
Formation.prototype.Init = function(deserialized = false)
{
this.shape = this.template.FormationShape;
this.maxTurningAngle = +this.template.MaxTurningAngle;
this.memberClassCombinationCache = new Map();
this.allMatchingClassCombinations = this.GenerateAllMatchingClassCombinations(this.template.SortingClasses);
this.sortingOrder = this.template.SortingOrder;
this.shiftRows = this.template.ShiftRows == "true";
this.separationMultiplier = {
"width": +this.template.UnitSeparationWidthMultiplier,
"depth": +this.template.UnitSeparationDepthMultiplier
};
this.sloppiness = +this.template.Sloppiness;
this.widthDepthRatio = +this.template.WidthDepthRatio;
this.minColumns = +(this.template.MinColumns || 0);
this.maxColumns = +(this.template.MaxColumns || 0);
this.maxRows = +(this.template.MaxRows || 0);
this.centerGap = +(this.template.CenterGap || 0);
if (this.template.AnimationVariants)
{
this.animationvariants = [];
const differentAnimationVariants = this.template.AnimationVariants.split(/\s*;\s*/);
// Loop over the different rectangulars that will map to different animation variants.
for (const rectAnimationVariant of differentAnimationVariants)
{
const [rect, replacementAnimationVariant] = rectAnimationVariant.split(/\s*:\s*/);
const [rows, columns] = rect.split(/\s*,\s*/);
const [minRow, maxRow] = rows.split(/\s*\.\.\s*/);
const [minColumn, maxColumn] = columns.split(/\s*\.\.\s*/);
this.animationvariants.push({
"minRow": +minRow,
"maxRow": +maxRow,
"minColumn": +minColumn,
"maxColumn": +maxColumn,
"name": replacementAnimationVariant
});
}
}
this.lastOrderVariant = undefined;
// Entity IDs currently belonging to this formation.
this.members = [];
this.memberPositions = {};
this.maxRowsUsed = 0;
this.maxColumnsUsed = [];
// Entities that have finished the original task.
this.finishedEntities = new Set();
// Members with a formation aura.
this.formationMembersWithAura = [];
this.width = 0;
this.depth = 0;
this.twinFormations = [];
// Distance from which two twin formations will merge into one.
this.formationSeparation = 0;
if (deserialized)
return;
Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer)
.SetInterval(this.entity, IID_Formation, "UpdateTwinFormationsForMerge", 1000, 1000, null);
};
Formation.prototype.Serialize = function()
{
const result = {};
for (const key of this.variablesToSerialize)
result[key] = this[key];
return result;
};
Formation.prototype.Deserialize = function(data)
{
this.Init(true);
for (const key in data)
this[key] = data[key];
};
/**
* Set the value from which two twin formations will become one.
*/
Formation.prototype.SetFormationSeparation = function(value)
{
this.formationSeparation = value;
};
Formation.prototype.GetSize = function()
{
return { "width": this.width, "depth": this.depth };
};
Formation.prototype.GetSpeedMultiplier = function()
{
return +this.template.SpeedMultiplier;
};
Formation.prototype.GetMemberCount = function()
{
return this.members.length;
};
Formation.prototype.GetMembers = function()
{
return this.members;
};
/**
* 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)
{
let closestMember = INVALID_ENTITY;
let closestDistance = Infinity;
for (const member of this.members)
{
if (filter && !filter(member))
continue;
const cmpMemberPosition = Engine.QueryInterface(member, IID_Position);
if (!cmpMemberPosition || !cmpMemberPosition.IsInWorld())
continue;
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;
closestDistance = dist;
}
}
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.
* Returns undefined if no members.
* TODO: Actually implement something like that. Currently this just returns
* the arbitrary first one.
*/
Formation.prototype.GetPrimaryMember = function()
{
return this.members[0];
};
/**
* Get the formation animation variant for a certain member of this formation.
* @param entity The entity ID to get the animation for.
* @return The name of the animation variant as defined in the template,
* e.g. "testudo_front" or undefined if does not exist.
*/
Formation.prototype.GetFormationAnimationVariant = function(entity)
{
if (!this.animationvariants || !this.animationvariants.length || !this.memberPositions[entity])
return undefined;
const row = this.memberPositions[entity].row;
const column = this.memberPositions[entity].column;
for (let i = 0; i < this.animationvariants.length; ++i)
{
let minRow = this.animationvariants[i].minRow;
if (minRow < 0)
minRow += this.maxRowsUsed + 1;
if (row < minRow)
continue;
let maxRow = this.animationvariants[i].maxRow;
if (maxRow < 0)
maxRow += this.maxRowsUsed + 1;
if (row > maxRow)
continue;
let minColumn = this.animationvariants[i].minColumn;
if (minColumn < 0)
minColumn += this.maxColumnsUsed[row] + 1;
if (column < minColumn)
continue;
let maxColumn = this.animationvariants[i].maxColumn;
if (maxColumn < 0)
maxColumn += this.maxColumnsUsed[row] + 1;
if (column > maxColumn)
continue;
return this.animationvariants[i].name;
}
return undefined;
};
Formation.prototype.SetFinishedEntity = function(ent)
{
// Rotate the entity to the correct angle.
const cmpPosition = Engine.QueryInterface(this.entity, IID_Position);
const cmpEntPosition = Engine.QueryInterface(ent, IID_Position);
if (cmpEntPosition && cmpEntPosition.IsInWorld() && cmpPosition && cmpPosition.IsInWorld())
cmpEntPosition.TurnTo(cmpPosition.GetRotation().y);
this.finishedEntities.add(ent);
};
Formation.prototype.UnsetFinishedEntity = function(ent)
{
this.finishedEntities.delete(ent);
};
Formation.prototype.ResetFinishedEntities = function()
{
this.finishedEntities.clear();
};
Formation.prototype.AreAllMembersFinished = function()
{
return this.finishedEntities.size === this.members.length;
};
/**
* Initialize the members of this formation.
* Must only be called once.
* All members must implement UnitAI.
*/
Formation.prototype.SetMembers = function(ents)
{
this.members = ents;
for (const ent of this.members)
{
const cmpUnitAI = Engine.QueryInterface(ent, IID_UnitAI);
cmpUnitAI.SetFormationController(this.entity);
const cmpAuras = Engine.QueryInterface(ent, IID_Auras);
if (cmpAuras && cmpAuras.HasFormationAura())
{
this.formationMembersWithAura.push(ent);
cmpAuras.ApplyFormationAura(ents);
}
}
// Note: We don't add the members to this.memberClassCombinationCache right away,
// it is filled lazily in ComputeFormationOffsets.
this.offsets = undefined;
// Locate this formation controller in the middle of its members.
this.MoveToMembersCenter();
// Compute the speed etc. of the formation.
this.ComputeMotionParameters();
};
/**
* 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 when under the member limit)
*/
Formation.prototype.RemoveMembers = function(ents, renamed = false)
{
if (!ents.length)
return;
if (!renamed)
this.offsets = undefined;
this.members = this.members.filter(ent => !ents.includes(ent));
for (const ent of ents)
{
this.finishedEntities.delete(ent);
const cmpUnitAI = Engine.QueryInterface(ent, IID_UnitAI);
cmpUnitAI.UpdateWorkOrders();
cmpUnitAI.UnsetFormationController();
// Clear cached member class
if (this.memberClassCombinationCache)
this.memberClassCombinationCache.delete(ent);
}
for (const ent of this.formationMembersWithAura)
{
const cmpAuras = Engine.QueryInterface(ent, IID_Auras);
cmpAuras.RemoveFormationAura(ents);
// The unit with the aura is also removed from the formation.
if (ents.includes(ent))
cmpAuras.RemoveFormationAura(this.members);
}
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 (this.members.length < +this.template.RequiredMemberCount)
{
this.Disband();
return;
}
this.ComputeMotionParameters();
};
/**
* 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, renamed = false)
{
if (!renamed)
this.offsets = undefined;
for (const ent of this.formationMembersWithAura)
{
const cmpAuras = Engine.QueryInterface(ent, IID_Auras);
cmpAuras.ApplyFormationAura(ents);
}
this.members = this.members.concat(ents);
for (const ent of ents)
{
const cmpUnitAI = Engine.QueryInterface(ent, IID_UnitAI);
cmpUnitAI.SetFormationController(this.entity);
if (!cmpUnitAI.GetOrders().length)
cmpUnitAI.SetNextState("FORMATIONMEMBER.IDLE");
const cmpAuras = Engine.QueryInterface(ent, IID_Auras);
if (cmpAuras && cmpAuras.HasFormationAura())
{
this.formationMembersWithAura.push(ent);
cmpAuras.ApplyFormationAura(this.members);
}
// Note: We don't add the new members to this.memberClassCombinationCache right away, it is filled lazily.
}
this.ComputeMotionParameters();
};
/**
* Remove all members and destroy the formation.
*/
Formation.prototype.Disband = function()
{
this.RemoveMembers(this.members);
// Hack: switch to a clean state to stop timers.
const cmpUnitAI = Engine.QueryInterface(this.entity, IID_UnitAI);
cmpUnitAI.UnitFsm.SwitchToNextState(cmpUnitAI, "");
Engine.QueryInterface(this.entity, IID_Position).MoveOutOfWorld();
this.DeleteTwinFormations();
Engine.DestroyEntity(this.entity);
};
/**
* Set all members to form up into the formation shape.
* @param {boolean} moveCenter - The formation center will be reinitialized
* to the center of the units.
* @param {boolean} force - All individual orders of the formation units are replaced,
* 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.ArrangeFormation = function(moveCenter, force, variant)
{
if (!this.members.length)
return;
const active = [];
const positions = [];
for (const ent of this.members)
{
const cmpPosition = Engine.QueryInterface(ent, IID_Position);
if (!cmpPosition || !cmpPosition.IsInWorld())
continue;
active.push(ent);
// Query the 2D position as the exact height calculation isn't needed,
// but bring the position to the correct coordinates.
positions.push(cmpPosition.GetPosition2D());
}
const cmpFormationUnitAI = Engine.QueryInterface(this.entity, IID_UnitAI);
const cmpPosition = Engine.QueryInterface(this.entity, IID_Position);
// Reposition the formation if we're told to or if we don't already have a position.
if (cmpPosition && (moveCenter || !cmpPosition.IsInWorld()))
{
const avgpos = Vector2D.average(positions);
const targetPosition = cmpFormationUnitAI.GetTargetPositions()[0];
const oldRotation = cmpPosition.GetRotation().y;
const newRotation = targetPosition !== undefined && avgpos.distanceToSquared(targetPosition) > g_RotateDistanceThreshold ? avgpos.angleTo(targetPosition) : oldRotation;
// When we are out of world or the angle difference is big, trigger repositioning.
// Do this before setting up the position, because then we will always be in world.
if (!cmpPosition.IsInWorld() || !this.DoesAngleDifferenceAllowTurning(newRotation, oldRotation))
this.offsets = undefined;
this.SetupPositionAndHandleRotation(avgpos.x, avgpos.y, newRotation, true);
}
this.lastOrderVariant = variant;
let offsetsChanged = false;
if (!this.offsets)
{
this.offsets = this.ComputeFormationOffsets(active, positions);
offsetsChanged = true;
}
let xMax = 0;
let yMax = 0;
let xMin = 0;
let yMin = 0;
if (force)
// Reset finishedEntities as FormationWalk is called.
this.ResetFinishedEntities();
for (let i = 0; i < this.offsets.length; ++i)
{
const offset = this.offsets[i];
const cmpUnitAI = Engine.QueryInterface(offset.ent, IID_UnitAI);
if (!cmpUnitAI)
{
warn("Entities without UnitAI in formation are not supported.");
continue;
}
const data =
{
"target": this.entity,
"x": offset.x,
"z": offset.y,
"offsetsChanged": offsetsChanged,
"variant": variant
};
cmpUnitAI.AddOrder("FormationWalk", data, !force);
xMax = Math.max(xMax, offset.x);
yMax = Math.max(yMax, offset.y);
xMin = Math.min(xMin, offset.x);
yMin = Math.min(yMin, offset.y);
}
this.width = xMax - xMin;
this.depth = yMax - yMin;
};
Formation.prototype.MoveToMembersCenter = function()
{
const positions = [];
let rotations = 0;
for (const ent of this.members)
{
const cmpPosition = Engine.QueryInterface(ent, IID_Position);
if (!cmpPosition || !cmpPosition.IsInWorld())
continue;
positions.push(cmpPosition.GetPosition2D());
rotations += cmpPosition.GetRotation().y;
}
const avgpos = Vector2D.average(positions);
this.SetupPositionAndHandleRotation(avgpos.x, avgpos.y, rotations / positions.length, false);
};
/**
* Set formation position.
* If formation is not in world at time this is called, set new rotation and flag
* for rangeManager. Also set the rotation if it is forced.
*/
Formation.prototype.SetupPositionAndHandleRotation = function(x, y, rot, forceRotation)
{
const cmpPosition = Engine.QueryInterface(this.entity, IID_Position);
if (!cmpPosition)
return;
const wasInWorld = cmpPosition.IsInWorld();
cmpPosition.JumpTo(x, y);
if (!forceRotation && wasInWorld)
return;
cmpPosition.TurnTo(rot);
if (!wasInWorld)
Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager).SetEntityFlag(this.entity, "normal", false);
};
Formation.prototype.GetAvgFootprint = function(active)
{
const footprints = [];
for (const ent of active)
{
const cmpFootprint = Engine.QueryInterface(ent, IID_Footprint);
if (cmpFootprint)
footprints.push(cmpFootprint.GetShape());
}
if (!footprints.length)
return { "width": 1, "depth": 1 };
const r = { "width": 0, "depth": 0 };
for (const shape of footprints)
{
if (shape.type == "circle")
{
r.width += shape.radius * 2;
r.depth += shape.radius * 2;
}
else if (shape.type == "square")
{
r.width += shape.width;
r.depth += shape.depth;
}
}
r.width /= footprints.length;
r.depth /= footprints.length;
return r;
};
/**
* Convert SortingClasses template into usable class combinations for member sorting.
*
* @param {string} sortingClassesText - The SortingClasses template value
* @example "Level1 | Level2 | Level3" where earlier levels have higher priority.
* Within each level, space-separated classes are separate possibilities.
* Classes from different levels are combined with '+' for AND logic.
*
* Example: "Melee Ranged | Cavalry Infantry | Citizen Champion Hero" creates:
* 1. "Melee+Cavalry+Citizen" (Level1 AND Level2 AND Level3)
* 2. "Melee+Cavalry+Champion"
* 3. "Melee+Cavalry+Hero"
* etc ...
*
* A member matches "Melee+Cavalry+Citizen" only if it has ALL THREE classes.
* The "+" delimiter indicates AND logic between levels.
*
* Note: The "+" sign can also be used within a level to create pre-made combinations,
* e.g., "Heavy+Infantry Light+Infantry" at a single level to set specific class combination entries.
*
* Each level implicitly includes an placeholder.
* This allows matching entities that don't have a class at that specific level.
*
* Example, with levels: "Melee | Infantry | Champion":
* - Generated combinations include:
* 1. "Melee+Infantry+Champion" (complete match)
* 2. "Melee+Infantry" (missing level 3)
* 3. "Melee+Champion" (missing level 2)
* etc ...
*
* @returns {Set} All possible class combinations, plus "Unsorted" as final fallback
*/
Formation.prototype.GenerateAllMatchingClassCombinations = function(sortingClassesText)
{
if (!sortingClassesText)
return new Set([this.UNSORTED_CLASS_COMBINATION]);
const levels = sortingClassesText.split(/\s*\|\s*/)
.map(level => level.split(/\s+/).filter(cls => cls.length && cls !== this.UNSORTED_CLASS_COMBINATION))
.filter(level => level.length);
// Adding the placeholder ("") ensures partial matches are caught rather than dropped to unsorted
const levelsWithPlaceholder = levels.map(level => level.concat(""));
// Generate combinations with '+' separator
const combinations = cartesianProduct(levelsWithPlaceholder).map(classes => classes.filter(cl => cl).join('+'));
return new Set(combinations.concat(this.UNSORTED_CLASS_COMBINATION));
};
Formation.prototype.GetMemberClassCombinations = function(ent)
{
const cached = this.memberClassCombinationCache.get(ent);
if (cached !== undefined)
return cached;
const classes = Engine.QueryInterface(ent, IID_Identity).GetClassesList();
const matchedClassCombination = Array.from(this.allMatchingClassCombinations).find(classCombination =>
MatchesClassList(classes, classCombination)
) || this.UNSORTED_CLASS_COMBINATION;
this.memberClassCombinationCache.set(ent, matchedClassCombination);
return matchedClassCombination;
};
Formation.prototype.ComputeFormationOffsets = function(active, positions)
{
const separation = this.GetAvgFootprint(active);
separation.width *= this.separationMultiplier.width;
separation.depth *= this.separationMultiplier.depth;
// Group entities by their classCombination
const classCombinations = {};
for (const classCombination of this.allMatchingClassCombinations)
classCombinations[classCombination] = [];
for (const i in active)
{
const ent = active[i];
const matchedClassCombinations = this.GetMemberClassCombinations(ent);
classCombinations[matchedClassCombinations].push({ "ent": ent, "pos": positions[i] });
}
const count = active.length;
let offsets = [];
let depth = Math.sqrt(count / this.widthDepthRatio);
if (this.maxRows && depth > this.maxRows)
depth = this.maxRows;
// Choose a sensible size/shape for the various formations, depending on number of units.
let cols = Math.ceil(count / Math.ceil(depth) + (this.shiftRows ? 0.5 : 0));
if (cols < this.minColumns)
cols = Math.min(count, this.minColumns);
if (this.maxColumns && cols > this.maxColumns && this.maxRows != depth)
cols = this.maxColumns;
// Define special formations here.
if (this.template.FormationShape == "special" && Engine.QueryInterface(this.entity, IID_Identity).GetGenericName() == "Scatter")
{
const width = Math.sqrt(count) * (separation.width + separation.depth) * 2.5;
for (let i = 0; i < count; ++i)
{
const obj = new Vector2D(randFloat(0, width), randFloat(0, width));
obj.row = 1;
obj.column = i + 1;
offsets.push(obj);
}
}
// For non-special formations, calculate the positions based on the number of entities.
this.maxColumnsUsed = [];
this.maxRowsUsed = 0;
if (this.shape != "special")
{
offsets = [];
let r = 0;
let left = count;
// While there are units left, start a new row in the formation.
while (left > 0)
{
// Save the position of the row.
const z = -r * separation.depth;
// Alternate between the left and right side of the center to have a symmetrical distribution.
let side = 1;
let n;
// Determine the number of entities in this row of the formation.
if (this.shape == "square")
{
n = cols;
if (this.shiftRows)
n -= r % 2;
}
else if (this.shape == "triangle")
{
if (this.shiftRows)
n = r + 1;
else
n = r * 2 + 1;
}
if (!this.shiftRows && n > left)
n = left;
for (let c = 0; c < n && left > 0; ++c)
{
// Switch sides for the next entity.
side *= -1;
let x;
if (n % 2 == 0)
x = side * (Math.floor(c / 2) + 0.5) * separation.width;
else
x = side * Math.ceil(c / 2) * separation.width;
if (this.centerGap)
{
// Don't use the center position with a center gap.
if (x == 0)
continue;
x += side * this.centerGap / 2;
}
const column = Math.ceil(n / 2) + Math.ceil(c / 2) * side;
const r1 = randFloat(-1, 1) * this.sloppiness;
const r2 = randFloat(-1, 1) * this.sloppiness;
offsets.push(new Vector2D(x + r1, z + r2));
offsets[offsets.length - 1].row = r + 1;
offsets[offsets.length - 1].column = column;
left--;
}
++r;
this.maxColumnsUsed[r] = n;
}
this.maxRowsUsed = r;
}
// Make sure the average offset is zero, as the formation is centered around that
// calculating offset distances without a zero average makes no sense, as the formation
// will jump to a different position any time.
const avgoffset = Vector2D.average(offsets);
offsets.forEach(function(o) {o.sub(avgoffset);});
// Sort the available places in certain ways.
// The places first in the list will contain the heaviest units as defined by the order
// of the classCombinations list.
if (this.sortingOrder == "fillFromTheSides")
offsets.sort(function(o1, o2) { return Math.abs(o1.x) < Math.abs(o2.x);});
else if (this.sortingOrder == "fillToTheCenter")
offsets.sort(function(o1, o2)
{
return Math.max(Math.abs(o1.x), Math.abs(o1.y)) < Math.max(Math.abs(o2.x), Math.abs(o2.y));
});
// Query the 2D position of the formation.
const realPositions = this.GetRealOffsetPositions(offsets);
// Use realistic place assignment,
// every soldier searches the closest available place in the formation.
const newOffsets = [];
for (const i of [...this.allMatchingClassCombinations].reverse())
{
const t = classCombinations[i];
if (!t.length)
continue;
const usedOffsets = offsets.splice(-t.length);
const usedRealPositions = realPositions.splice(-t.length);
for (const entPos of t)
{
const closestOffsetId = this.TakeClosestOffset(entPos, usedRealPositions, usedOffsets);
usedRealPositions.splice(closestOffsetId, 1);
newOffsets.push(usedOffsets.splice(closestOffsetId, 1)[0]);
newOffsets[newOffsets.length - 1].ent = entPos.ent;
}
}
return newOffsets;
};
/**
* Search the closest position in the realPositions list to the given entity.
* @param entPos - Object with entity position and entity ID.
* @param realPositions - The world coordinates of the available offsets.
* @param offsets
* @return The index of the closest offset position.
*/
Formation.prototype.TakeClosestOffset = function(entPos, realPositions, offsets)
{
const pos = entPos.pos;
let closestOffsetId = -1;
let offsetDistanceSq = Infinity;
for (let i = 0; i < realPositions.length; ++i)
{
const distSq = pos.distanceToSquared(realPositions[i]);
if (distSq < offsetDistanceSq)
{
offsetDistanceSq = distSq;
closestOffsetId = i;
}
}
this.memberPositions[entPos.ent] = { "row": offsets[closestOffsetId].row, "column": offsets[closestOffsetId].column };
return closestOffsetId;
};
/**
* Get the world positions for a list of offsets in this formation.
*/
Formation.prototype.GetRealOffsetPositions = function(offsets)
{
const cmpPosition = Engine.QueryInterface(this.entity, IID_Position);
const pos = cmpPosition.GetPosition2D();
const rot = cmpPosition.GetRotation().y;
const sin = Math.sin(rot);
const cos = Math.cos(rot);
const offsetPositions = [];
// Calculate the world positions.
for (const o of offsets)
offsetPositions.push(new Vector2D(pos.x + o.y * sin + o.x * cos, pos.y + o.y * cos - o.x * sin));
return offsetPositions;
};
/**
* Returns true if the difference between two given angles (in radians)
* are smaller than the maximum turning angle of the formation and therfore allow
* the formation turn without reassigning positions.
*/
Formation.prototype.DoesAngleDifferenceAllowTurning = function(a1, a2)
{
const d = Math.abs(a1 - a2) % (2 * Math.PI);
return d < this.maxTurningAngle || d > 2 * Math.PI - this.maxTurningAngle;
};
/**
* Set formation controller's speed based on its current members.
*/
Formation.prototype.ComputeMotionParameters = function()
{
if (!this.members.length)
return;
let minSpeed = Infinity;
let minAcceleration = Infinity;
let maxClearance = 0;
let maxPassClass = "default";
const cmpPathfinder = Engine.QueryInterface(SYSTEM_ENTITY, IID_Pathfinder);
for (const ent of this.members)
{
const cmpUnitMotion = Engine.QueryInterface(ent, IID_UnitMotion);
if (!cmpUnitMotion)
continue;
minSpeed = Math.min(minSpeed, cmpUnitMotion.GetWalkSpeed());
minAcceleration = Math.min(minAcceleration, cmpUnitMotion.GetAcceleration());
const passClass = cmpUnitMotion.GetPassabilityClassName();
const clearance = cmpPathfinder.GetClearance(cmpPathfinder.GetPassabilityClass(passClass));
if (clearance > maxClearance)
{
maxClearance = clearance;
maxPassClass = passClass;
}
}
minSpeed *= this.GetSpeedMultiplier();
const cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion);
cmpUnitMotion.SetSpeedMultiplier(minSpeed / cmpUnitMotion.GetWalkSpeed());
cmpUnitMotion.SetAcceleration(minAcceleration);
cmpUnitMotion.SetPassabilityClassName(maxPassClass);
};
Formation.prototype.UpdateTwinFormationsForMerge = function()
{
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)
{
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(otherFormationID, IID_Position);
const cmpOtherFormation = Engine.QueryInterface(otherFormationID, IID_Formation);
if (!cmpPosition || !cmpOtherPosition || !cmpOtherFormation ||
!cmpPosition.IsInWorld() || !cmpOtherPosition.IsInWorld())
continue;
const thisPosition = cmpPosition.GetPosition2D();
const otherPosition = cmpOtherPosition.GetPosition2D();
const dist = thisPosition.distanceTo(otherPosition);
const otherSize = cmpOtherFormation.GetSize();
const minDist = baseDistance + Math.max(otherSize.width, otherSize.depth) / 2;
if (minDist < dist)
continue;
const otherMembers = cmpOtherFormation.members;
// The other formation will get disbanded for having no members
cmpOtherFormation.RemoveMembers(otherMembers);
// Merge the members from the other formation into this one
this.AddMembers(otherMembers, true);
// Remove the merged formation from twin formations list
this.twinFormations.splice(i, 1);
this.UpdateFormation(true, true);
}
};
Formation.prototype.ResetOrderVariant = function()
{
this.lastOrderVariant = undefined;
};
Formation.prototype.OnGlobalOwnershipChanged = function(msg)
{
// When an entity is captured or destroyed, it should no longer be
// controlled by this formation.
if (this.members.indexOf(msg.entity) != -1)
this.RemoveMembers([msg.entity]);
if (msg.entity === this.entity && msg.to !== INVALID_PLAYER)
Engine.QueryInterface(this.entity, IID_Visual)?.SetVariant("animationVariant", QueryPlayerIDInterface(msg.to, IID_Identity).GetCiv());
};
Formation.prototype.OnGlobalEntityRenamed = function(msg)
{
if (this.members.indexOf(msg.entity) === -1)
return;
if (this.finishedEntities.delete(msg.entity))
this.finishedEntities.add(msg.newentity);
// First remove the old member to be able to reuse its position.
this.RemoveMembers([msg.entity], true);
this.AddMembers([msg.newentity], true);
this.memberPositions[msg.newentity] = this.memberPositions[msg.entity];
delete this.memberPositions[msg.entity];
if (this.resetOffsetsScheduled === undefined)
{
this.resetOffsetsScheduled = true;
// Schedule offset reset for the next tick so that it reorders if necessary.
const cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer);
cmpTimer.SetTimeout(this.entity, IID_Formation, "ResetOffsetsAndUpdate", 0, null);
}
};
Formation.prototype.ResetOffsetsAndUpdate = function()
{
this.resetOffsetsScheduled = undefined;
this.offsets = undefined;
this.UpdateFormation(false, false);
};
Formation.prototype.RegisterTwinFormation = function(entity)
{
const cmpFormation = Engine.QueryInterface(entity, IID_Formation);
if (!cmpFormation)
return;
this.twinFormations.push(entity);
cmpFormation.twinFormations.push(this.entity);
};
Formation.prototype.DeleteTwinFormations = function()
{
for (const ent of this.twinFormations)
{
const cmpFormation = Engine.QueryInterface(ent, IID_Formation);
if (cmpFormation)
cmpFormation.twinFormations.splice(cmpFormation.twinFormations.indexOf(this.entity), 1);
}
this.twinFormations = [];
};
Formation.prototype.LoadFormation = function(newTemplate)
{
const newFormation = ChangeEntityTemplate(this.entity, 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)
{
const members = clone(this.members);
this.Disband();
Engine.QueryInterface(msg.newentity, IID_Formation).SetMembers(members);
};
/**
* Final fallback class combination for entities that don't match any combination.
* Unsorted members are generally placed at the back/last of the formation.
*/
Formation.prototype.UNSORTED_CLASS_COMBINATION = "Unsorted";
Engine.RegisterComponentType(IID_Formation, "Formation", Formation);