1
0
forked from mirrors/0ad

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
This commit is contained in:
Atrik
2026-01-11 00:36:40 +01:00
committed by Vantha
parent 40dfde9890
commit 065ecdbdf8
2 changed files with 110 additions and 27 deletions
@@ -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])),
[[]]
);
}
@@ -33,7 +33,7 @@ Formation.prototype.Schema =
"<element name='ShiftRows' a:help='Set the value to true to shift subsequent rows.'>" +
"<text/>" +
"</element>" +
"<element name='SortingClasses' a:help='Classes will be added to the formation in this order. Where the classes will be added first depends on the formation.'>" +
"<element name='SortingClasses' a:help='Determine units` position in formation based on class hierarchy. Use | to separate priority levels. Within a level, + creates AND combinations. Example: &#x201C;Heavy+Melee Melee Heavy Light | Cavalry Infantry&#x201D; positions &#x201C;Heavy Melee Cavalry&#x201D; units first, then &#x201C;Heavy Melee Infantry&#x201D;, then any &#x201C;Melee&#x201D; unit etc.'>" +
"<text/>" +
"</element>" +
"<optional>" +
@@ -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<string>} 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);