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