From 065ecdbdf8add509c18c5f11f69dfb66c60329a2 Mon Sep 17 00:00:00 2001 From: Atrik Date: Sun, 11 Jan 2026 00:36:40 +0100 Subject: [PATCH] Implement level class sorting for formations Add support for multi-level priority sorting of entity classes in formations. fixes #7547 Change formation template's default sorting order using multi-level sorting. fixes #6873 --- .../data/mods/public/globalscripts/utility.js | 17 +++ .../public/simulation/components/Formation.js | 120 ++++++++++++++---- 2 files changed, 110 insertions(+), 27 deletions(-) diff --git a/binaries/data/mods/public/globalscripts/utility.js b/binaries/data/mods/public/globalscripts/utility.js index 6ecee8b6fd..46a539861b 100644 --- a/binaries/data/mods/public/globalscripts/utility.js +++ b/binaries/data/mods/public/globalscripts/utility.js @@ -142,3 +142,20 @@ function listFiles(path, extension, recurse) return Engine.ListDirectoryFiles(path, "*" + extension, recurse).map(filename => filename.slice(path.length, -extension.length)); } +/** + * Computes the Cartesian Product of multiple arrays + * + * Generates all possible ordered combinations by taking one element from each input array. + * This is equivalent to nested loops over all input arrays, producing every possible combination. + * + * @example + * cartesianProduct([[1, 2], ['a', 'b']]) + * -> [[1,'a'],[1,'b'],[2,'a'],[2,'b']] + */ +function cartesianProduct(arrays) +{ + return arrays.reduce((acc, curr) => + acc.flatMap(a => curr.map(c => [...a, c])), + [[]] + ); +} \ No newline at end of file diff --git a/binaries/data/mods/public/simulation/components/Formation.js b/binaries/data/mods/public/simulation/components/Formation.js index 0323b44cf0..8341ccc83f 100644 --- a/binaries/data/mods/public/simulation/components/Formation.js +++ b/binaries/data/mods/public/simulation/components/Formation.js @@ -33,7 +33,7 @@ Formation.prototype.Schema = "" + "" + "" + - "" + + "" + "" + "" + "" + @@ -99,7 +99,10 @@ Formation.prototype.Init = function(deserialized = false) { this.shape = this.template.FormationShape; this.maxTurningAngle = +this.template.MaxTurningAngle; - this.sortingClasses = this.template.SortingClasses.split(/\s+/g); + + this.memberClassCombinationCache = new Map(); + this.allMatchingClassCombinations = this.GenerateAllMatchingClassCombinations(this.template.SortingClasses); + this.sortingOrder = this.template.SortingOrder; this.shiftRows = this.template.ShiftRows == "true"; this.separationMultiplier = { @@ -358,6 +361,8 @@ Formation.prototype.SetMembers = function(ents) 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. @@ -393,6 +398,10 @@ Formation.prototype.RemoveMembers = function(ents, renamed = false) 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) @@ -457,6 +466,7 @@ Formation.prototype.AddMembers = function(ents) 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(); @@ -642,37 +652,87 @@ Formation.prototype.GetAvgFootprint = function(active) 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(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; - const sortingClasses = this.sortingClasses.slice(); - sortingClasses.push("Unknown"); - - // The entities will be assigned to positions in the formation in - // the same order as the types list is ordered. - const types = {}; - for (let i = 0; i < sortingClasses.length; ++i) - types[sortingClasses[i]] = []; + // Group entities by their classCombination + const classCombinations = {}; + for (const classCombination of this.allMatchingClassCombinations) + classCombinations[classCombination] = []; for (const i in active) { - const cmpIdentity = Engine.QueryInterface(active[i], IID_Identity); - const classes = cmpIdentity.GetClassesList(); - let done = false; - for (let c = 0; c < sortingClasses.length; ++c) - { - if (classes.indexOf(sortingClasses[c]) > -1) - { - types[sortingClasses[c]].push({ "ent": active[i], "pos": positions[i] }); - done = true; - break; - } - } - if (!done) - types.Unknown.push({ "ent": active[i], "pos": positions[i] }); + const ent = active[i]; + const matchedClassCombinations = this.GetMemberClassCombinations(ent); + classCombinations[matchedClassCombinations].push({ "ent": ent, "pos": positions[i] }); } const count = active.length; @@ -774,7 +834,7 @@ Formation.prototype.ComputeFormationOffsets = function(active, positions) // 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 types list. + // 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") @@ -789,9 +849,9 @@ Formation.prototype.ComputeFormationOffsets = function(active, positions) // Use realistic place assignment, // every soldier searches the closest available place in the formation. const newOffsets = []; - for (const i of sortingClasses.reverse()) + for (const i of [...this.allMatchingClassCombinations].reverse()) { - const t = types[i]; + const t = classCombinations[i]; if (!t.length) continue; const usedOffsets = offsets.splice(-t.length); @@ -1123,4 +1183,10 @@ Formation.prototype.OnEntityRenamed = function(msg) 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);