More versatile placement of research buttons

This patch allows techs to optionally define a property "placeBelow"
specifying a combination of class requirements. The selection panel then
tries to place their research button below the training button whose
unit matches these classes -- if there is only and exactly one.
This is useful for techs only affecting a single type of unit: it e.g.
allows always placing the fishing nets research button below the fishing
boats training button.
To prevent duplicating information in "placeBelow" (e.g. from
"affects"), it can also be set to two magic values: "{AffectedUnit}" and
"{UnlockedUnit}". In practice, many techs use those, but class
combination remain useful for granular control in very specific cases.

Regarding tech pairs, as explained in a comment, their two research
buttons can now be placed in three different arrangements (with
descending preference):
1. Vertically below a single training button.
2. Horizontally below two adjacent training buttons.
3. Horizontally adjacent in the bottom row (below no training buttons).

Vertically in the third and fourth rows (below no training button) --
how it was previously -- is no longer done as it'd conflict with and
doesn't fit the new layout and the clear separation between "generic"
and "specific" techs.

Whenever a research button can't be placed in their preferred location
because it is already occupied, the code simply falls back to the default
position in the bottom row.

This patch also adds a new game option to hide the new small arrows above
the research buttons in order to give more experienced players who already
know the techs well the possibility to reduce visual clutter. By
default, the arrows are shown.
This commit is contained in:
Vantha 2025-09-24 16:52:59 +02:00
parent 107a49caf1
commit 2a88a41959
52 changed files with 378 additions and 98 deletions

View file

@ -516,6 +516,7 @@ defaultformation = "special/formations/box" ; For walking orders, automatically
formationwalkonly = "true" ; Formations are disabled when giving gather/attack/... orders.
howtoshownames = 0 ; Whether the specific names are show as default, as opposed to the generic names. And whether the secondary names are shown. (0 - show both; specific names primary, 1 - show both; generic names primary, 2 - show only specific names, 3 - show only generic names)
selectformationasone = "true" ; Whether to select formations as a whole by default.
techarrows = true ; Whether to show an arrow above some techs that indicates which unit they affect or unlock.
[gui.session.minimap]
; Icons that are displayed for some entities on a minimap.

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0af0db2f9dbdd451452ef7968fef7c73a4d91faf8b9d6663a3f01972ae5364c6
size 1588

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cc488cbc99d9c9a16b287cc9fb39fc0e2a25f2046f30a3b39ae6855ae90883bf
size 798

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:79fcc1b450dc61d7b1df551785a8a116b033e54e41d256adf95c05f66cb1c07e
size 798

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d715163e253b20dfeeb4a9509b0a0057d607b27536597e1d3442a939f9c2ee83
size 1649

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:aeb1f48c5cc648e832e42f474166f3ba6bb052c724d1066ee828d6c8edce0371
size 6361
oid sha256:11eb2025f1b1db59b1cfe0ed35eea70d0cb0b48eba8f704a09eea2b0be0ca4dc
size 6650

View file

@ -592,6 +592,8 @@ function GetTechnologyDataHelper(template, civ, resources)
ret.tooltip = template.tooltip;
ret.requirementsTooltip = template.requirementsTooltip || "";
if (template.placeBelow)
ret.placeBelow = template.placeBelow;
return ret;
}

View file

@ -640,6 +640,12 @@
"tooltip": "Show detailed tooltips for trainable units in structures.",
"config": "showdetailedtooltips"
},
{
"type": "boolean",
"label": "Tech arrows",
"tooltip": "Show an arrow above specific techs that points to the unit that they affect or unlock.",
"config": "gui.session.techarrows"
},
{
"type": "dropdown",
"label": "Naming of entities",

View file

@ -647,8 +647,30 @@ g_SelectionPanels.Research = {
return 10;
},
"rowLength": 10,
"init": function()
{
const updateAffectsIconVisibility = () =>
{
this.helper.showAffectsIcons = Engine.ConfigDB_GetValue("user", "gui.session.techarrows") === "true";
};
registerConfigChangeHandler(changes =>
{
if (changes.has("gui.session.techarrows"))
updateAffectsIconVisibility();
// They will be rerendered with the new visibility next frame.
});
updateAffectsIconVisibility();
},
"reset": function()
{
this.helper.occupiedPositions = new Set();
this.helper.bottomRowButtonCount = 0;
},
"getItems": function(unitEntStates)
{
if (getNumberOfRightPanelButtons() >= this.rowLength * 2)
return [];
let ret = [];
if (unitEntStates.length == 1)
{
@ -690,8 +712,7 @@ g_SelectionPanels.Research = {
k => item.techCostMultiplier[k] == state.researcher.techCostMultiplier[k])
));
if (filteredTechs.length + ret.length <= this.getMaxNumberOfItems() &&
getNumberOfRightPanelButtons() <= this.getMaxNumberOfItems() * (filteredTechs.some(tech => !!tech.pair) ? 1 : 2))
if (filteredTechs.length + ret.length <= this.getMaxNumberOfItems())
ret = ret.concat(filteredTechs.map(tech => ({
"tech": tech,
"techCostMultiplier": state.researcher.techCostMultiplier,
@ -704,77 +725,277 @@ g_SelectionPanels.Research = {
"hideItem": function(i, rowLength) // Called when no item is found
{
Engine.GetGUIObjectByName("unitResearchButton[" + i + "]").hidden = true;
// We also remove the paired tech and the pair symbol
Engine.GetGUIObjectByName("unitResearchButton[" + (i + rowLength) + "]").hidden = true;
Engine.GetGUIObjectByName("unitResearchPair[" + i + "]").hidden = true;
// Remove the button it would have been paired with as well.
Engine.GetGUIObjectByName("unitResearchButton[" + (i + this.getMaxNumberOfItems()) + "]").hidden = true;
},
"setupButton": function(data)
{
if (!data.item.tech)
{
g_SelectionPanels.Research.hideItem(data.i, data.rowLength);
this.hideItem(data.i, data.rowLength);
return false;
}
// Start position (start at the bottom)
let position = data.i + data.rowLength;
// There are twice as many button objects than this.getMaxNumberOfItems()
// This is because each item could be a tech pair and need a second one in addition to the one at data.i
data.j = data.i + this.getMaxNumberOfItems();
// Only show the top button for pairs
if (!data.item.tech.pair)
Engine.GetGUIObjectByName("unitResearchButton[" + data.i + "]").hidden = true;
const playerState = GetSimState().players[data.player];
// Set up the tech connector
const pair = Engine.GetGUIObjectByName("unitResearchPair[" + data.i + "]");
pair.hidden = data.item.tech.pair == null;
setPanelObjectPosition(pair, data.i, data.rowLength);
// Handle one or two techs (tech pair)
const player = data.player;
const playerState = GetSimState().players[player];
for (const tech of (data.item.tech.pair || [data.item.tech]))
if (data.item.tech.pair)
{
// Don't change the object returned by GetTechnologyData
const template = clone(GetTechnologyData(tech, playerState.civ));
if (!template)
return false;
const firstTemplate = GetTechnologyData(data.item.tech.pair[0], playerState.civ);
const secondTemplate = GetTechnologyData(data.item.tech.pair[1], playerState.civ);
// Not allowed by civ.
if (!template.reqs)
// template.reqs is false if the tech isn't researchable by the current civ.
const firstResearchable = !!firstTemplate?.reqs;
const secondResearchable = !!secondTemplate?.reqs;
if (firstResearchable && secondResearchable)
// Ideal/expected case: Display both techs in a pair.
return this.helper.setupButtonPair(data, data.item.tech.pair[0], data.item.tech.pair[1], firstTemplate,
secondTemplate, playerState);
// At least one of the two is not valid or researchable. If the other one is, display it as a single tech
// on its own.
if (firstResearchable && !secondResearchable)
return this.helper.setupSingleButton(data, data.item.tech.pair[0], firstTemplate, playerState);
if (!firstResearchable && secondResearchable)
return this.helper.setupSingleButton(data, data.item.tech.pair[1], secondTemplate, playerState);
// Neither of the two are valid and researchable.
this.hideItem(data.i, data.rowLength);
return false;
}
const template = GetTechnologyData(data.item.tech, playerState.civ);
// template.reqs is false if the tech isn't researchable by the current civ.
if (template?.reqs)
return this.helper.setupSingleButton(data, data.item.tech, template, playerState);
this.hideItem(data.i, data.rowLength);
return false;
},
"helper": {
// Techs can optionally define a placeBelow property that specifies a unit whose training button they want to be placed below.
// It can be:
// - "{UnlockedUnit}": the first unit whose requirements (of the Identity component) contain the tech.
// - "{AffectedUnit}": the first unit whose stats are modified (receives buffs or debuffs) by the tech.
// - class combination: the first unit whose identity classes match that combination.
"findTargetTrainingButton": function(data, techName, template)
{
// Also check whether the other right panel buttons (training, constructing, upgrading) reach the second row.
// In that case, we want to place all techs in the bottom row. Research buttons should never be placed in the
// same row as these.
if (!template.placeBelow || getNumberOfRightPanelButtons() > data.rowLength)
return -1;
const indices = [];
if (template.placeBelow === "{UnlockedUnit}")
{
// One of the pair may still be researchable by the current civ,
// hence don't hide everything.
Engine.GetGUIObjectByName("unitResearchButton[" + data.i + "]").hidden = true;
pair.hidden = true;
continue;
getAllTrainableEntitiesFromSelection().forEach((trainableTemplate, i) =>
{
if (GetTemplateData(trainableTemplate, data.player)?.requirements?.Techs?._string.split(/\s+/).includes(techName))
indices.push(i);
});
}
else
{
let targetClassList;
if (template.placeBelow === "{AffectedUnit}")
{
const affectsList = (template.affects || []);
for (const mod of template.modifications)
if (mod.affects)
affectsList.push(mod.affects);
targetClassList = affectsList.map(classes => classes.split(/\s+/));
}
else
targetClassList = [template.placeBelow.split(/\s+/)];
getAllTrainableEntitiesFromSelection().forEach((trainableTemplate, i) =>
{
if (MatchesClassList(GetTemplateData(trainableTemplate, data.player).visibleIdentityClasses, targetClassList))
indices.push(i);
});
}
// Only choose a training button if it's the only matching one.
if (indices.length !== 1)
return -1;
// Make sure to account for the other buttons placed before the unit training ones.
return indices[0] + ["Construction", "Pack", "Gate", "Upgrade"].reduce((total, panel) =>
total + g_unitPanelButtons[panel], 0
);
},
"setupSingleButton": function(data, techName, template, playerState)
{
// The item is not a tech pair. So hide the button that data.button would have been paired with.
Engine.GetGUIObjectByName("unitResearchButton[" + data.j + "]").hidden = true;
// Note: The GUI object container of the research buttons (unlike the one of the training buttons) only reaches up to the second row.
// This means that, for example, a research button with position 5 is located directly one row under a training button with position 5.
let position = this.findTargetTrainingButton(data, techName, template);
let placeInBottomRow = position == -1;
if (!placeInBottomRow && this.occupiedPositions.has(position))
{
// Try to fall back to the third (second-to-bottom) row.
position += data.rowLength;
if (this.occupiedPositions.has(position))
// Both positions below the target unit are already used by other techs.
// Note: Ideally this should never occur. Two techs per unit should be the limit. This here is just edge case handling.
placeInBottomRow = true;
}
if (placeInBottomRow)
{
// Try to move it to the fourth (bottom) row.
if (this.bottomRowButtonCount >= data.rowLength)
return false; // Bottom row is full, we can't display it.
position = this.bottomRowButtonCount + data.rowLength * 2;
}
for (const res in template.cost)
template.cost[res] *= data.item.techCostMultiplier[res] !== undefined ? data.item.techCostMultiplier[res] : 1;
Engine.GetGUIObjectByName("unitResearchVerticalPairIcon[" + data.i + "]").hidden = true;
Engine.GetGUIObjectByName("unitResearchHorizontalPairIcon[" + data.i + "]").hidden = true;
// When it's not "active", it's grayed out.
const buttonActive = this.buildButton(data, techName, template, position, playerState, data.button, data.icon);
this.buildAffectsIcon(data.i, !placeInBottomRow, buttonActive);
return true;
},
"setupButtonPair": function(data, firstTechName, secondTechName, firstTemplate, secondTemplate, playerState)
{
// Note: The GUI object container of the research buttons (unlike the one of the training buttons) only
// reaches up to the second row. This means that, for example, a research button with position 5 is located
// directly one row under a training button with position 5.
let firstPosition = this.findTargetTrainingButton(data, firstTechName, firstTemplate);
let secondPosition = this.findTargetTrainingButton(data, secondTechName, secondTemplate);
// Possible placements of tech pair with descending preference:
// - Vertically below a single unit.
// - Horizontally below two adjacent units.
// - Horizontally adjacent below no unit in the bottom row.
// Only ever place either below a unit, if the other can be too and below the same or an adjacent one.
let placeInBottomRow = firstPosition == -1 || secondPosition == -1 || Math.abs(firstPosition - secondPosition) > 1;
let placeHorizontally = true;
if (!placeInBottomRow && firstPosition === secondPosition)
{
// Both want to be placed under the same unit.
// Try to place the pair vertically by moving the second one down to the third (second-to-bottom) row,
// below the first one.
secondPosition += data.rowLength;
if (this.occupiedPositions.has(firstPosition) || this.occupiedPositions.has(secondPosition))
placeInBottomRow = true;
else
placeHorizontally = false;
}
else if (!placeInBottomRow && (this.occupiedPositions.has(firstPosition) || this.occupiedPositions.has(secondPosition)))
{
// At least one of the two respective positions in the second (third-to-bottom) row is occupied.
// So try move both to the third.
firstPosition += data.rowLength;
secondPosition += data.rowLength;
if (this.occupiedPositions.has(firstPosition) || this.occupiedPositions.has(secondPosition))
// Neither the two positions in the second row nor the third row below the target training buttons
// are available.
placeInBottomRow = true;
}
if (placeInBottomRow)
{
// Try to move both to the bottom row.
if (this.bottomRowButtonCount >= data.rowLength - 1)
// Not enough space in the bottom row for both of them. We can't display them.
return false;
firstPosition = this.bottomRowButtonCount + data.rowLength * 2;
secondPosition = firstPosition + 1;
}
// Note: the button indices here aren't related to positioning at all.
const firstButtonIndex = data.i;
const secondButtonIndex = data.j;
const firstButton = data.button;
const secondButton = Engine.GetGUIObjectByName("unitResearchButton[" + secondButtonIndex + "]");
const firstIcon = data.icon;
const secondIcon = Engine.GetGUIObjectByName("unitResearchIcon[" + secondButtonIndex + "]");
// When it's not "active", it's grayed out.
const firstButtonActive = this.buildButton(data, firstTechName, firstTemplate, firstPosition, playerState,
firstButton, firstIcon);
this.buildAffectsIcon(firstButtonIndex, !placeInBottomRow, firstButtonActive);
// When it's not "active", it's grayed out.
const secondButtonActive = this.buildButton(data, secondTechName, secondTemplate, secondPosition, playerState,
secondButton, secondIcon);
this.buildAffectsIcon(secondButtonIndex, !placeInBottomRow && placeHorizontally, secondButtonActive);
this.buildPairIcon(false, firstButtonIndex, placeHorizontally && secondPosition > firstPosition, firstButtonActive);
this.buildPairIcon(false, secondButtonIndex, placeHorizontally && secondPosition < firstPosition, secondButtonActive);
this.buildPairIcon(true, firstButtonIndex, !placeHorizontally, firstButtonActive);
this.buildPairIcon(true, secondButtonIndex, false, secondButtonActive);
// While hovering over either button, show a cross over the other one.
// TODO: The following lines have to be executed only once, technically, and not every this function is called.
const firstUnchosenIcon = Engine.GetGUIObjectByName("unitResearchUnchosenIcon[" + firstButtonIndex + "]");
const secondUnchosenIcon = Engine.GetGUIObjectByName("unitResearchUnchosenIcon[" + secondButtonIndex + "]");
firstButton.onMouseEnter = () => { secondUnchosenIcon.hidden = false; };
firstButton.onMouseLeave = () => { secondUnchosenIcon.hidden = true; };
secondButton.onMouseEnter = () => { firstUnchosenIcon.hidden = false; };
secondButton.onMouseLeave = () => { firstUnchosenIcon.hidden = true; };
return true;
},
"buildAffectsIcon": function(i, show, enable)
{
const icon = Engine.GetGUIObjectByName("unitResearchAffectsIcon[" + i + "]");
icon.hidden = !show || !this.showAffectsIcons;
if (!icon.hidden)
icon.sprite = "stretched:session/icons/" + (enable ? "tech_affects.png" : "tech_affects_disabled.png");
},
"buildPairIcon": function(vertical, i, show, enable)
{
const icon = Engine.GetGUIObjectByName("unitResearch" + (vertical ? "Vertical" : "Horizontal") + "PairIcon[" + i + "]");
icon.hidden = !show;
if (show)
icon.sprite = "stretched:session/icons/" +
(vertical ?
enable ? "vertical_tech_pair.png" : "vertical_tech_pair_disabled.png" :
enable ? "horizontal_tech_pair.png" : "horizontal_tech_pair_disabled.png");
},
"buildButton": function(baseData, techName, template, position, playerState, button, icon)
{
// Make sure to not modify the original template.
const adaptedTemplate = clone(template);
for (const res in adaptedTemplate.cost)
adaptedTemplate.cost[res] *=
baseData.item.techCostMultiplier[res] !== undefined ? baseData.item.techCostMultiplier[res] : 1;
const neededResources = Engine.GuiInterfaceCall("GetNeededResources", {
"cost": template.cost,
"player": player
"cost": adaptedTemplate.cost,
"player": baseData.player
});
const requirementsPassed = Engine.GuiInterfaceCall("CheckTechnologyRequirements", {
"tech": tech,
"player": player
"tech": techName,
"player": baseData.player
});
const button = Engine.GetGUIObjectByName("unitResearchButton[" + position + "]");
const icon = Engine.GetGUIObjectByName("unitResearchIcon[" + position + "]");
const tooltips = [
getEntityNamesFormatted,
getEntityTooltip,
getEntityCostTooltip,
getTemplateViewerOnRightClickTooltip
].map(func => func(template));
].map(func => func(adaptedTemplate));
if (!requirementsPassed)
{
let tip = template.requirementsTooltip;
const reqs = template.reqs;
let tip = adaptedTemplate.requirementsTooltip;
const reqs = adaptedTemplate.reqs;
for (const req of reqs)
{
if (!req.entities)
@ -819,39 +1040,27 @@ g_SelectionPanels.Research = {
button.onPress = (t => function()
{
addResearchToQueue(data.item.researchFacilityId, t);
})(tech);
addResearchToQueue(baseData.item.researchFacilityId, t);
})(techName);
const showTemplateFunc = (t => function()
{
showTemplateDetails(
t,
GetTemplateData(data.unitEntStates.find(state => state.id == data.item.researchFacilityId).template).nativeCiv);
GetTemplateData(baseData.unitEntStates.find(state => state.id == baseData.item.researchFacilityId).template).nativeCiv);
});
button.onPressRight = showTemplateFunc(tech);
button.onPressRightDisabled = showTemplateFunc(tech);
if (data.item.tech.pair)
{
// On mouse enter, show a cross over the other icon
const unchosenIcon = Engine.GetGUIObjectByName("unitResearchUnchosenIcon[" + (position + data.rowLength) % (2 * data.rowLength) + "]");
button.onMouseEnter = function()
{
unchosenIcon.hidden = false;
};
button.onMouseLeave = function()
{
unchosenIcon.hidden = true;
};
}
button.onPressRight = showTemplateFunc(techName);
button.onPressRightDisabled = showTemplateFunc(techName);
button.hidden = false;
let modifier = "";
let isActive = true;
if (!requirementsPassed)
{
button.enabled = false;
modifier += "color:0 0 0 127:grayscale:";
isActive = false;
}
else if (neededResources)
{
@ -859,26 +1068,32 @@ g_SelectionPanels.Research = {
modifier += resourcesToAlphaMask(neededResources) + ":";
}
else
button.enabled = controlsPlayer(data.player);
button.enabled = controlsPlayer(baseData.player);
if (data.item.isUpgrading)
if (baseData.item.isUpgrading)
{
button.enabled = false;
modifier += "color:0 0 0 127:grayscale:";
isActive = false;
button.tooltip += "\n" + objectionFont(translate("Cannot research while upgrading."));
}
if (template.icon)
icon.sprite = modifier + "stretched:session/portraits/" + template.icon;
if (adaptedTemplate.icon)
icon.sprite = modifier + "stretched:session/portraits/" + adaptedTemplate.icon;
setPanelObjectPosition(button, position, data.rowLength);
this.occupiedPositions.add(position);
if (position >= 2 * baseData.rowLength)
this.bottomRowButtonCount++;
// Prepare to handle the top button (if any)
position -= data.rowLength;
// The panel is a bit higher than 4 * baseData.rowLength, which allows us to visibility anchor the buttons
// in the bottom row to the bottom by moving them down those few pixels. Else the gap would be at the bottom.
// This creates a small spatial separation between the "generic" techs in the bottom row and the "specific"
// techs above them.
const vOffset = position >= baseData.rowLength * 2 ? 6 : 0;
setPanelObjectPosition(button, position, baseData.rowLength, 1, 1, vOffset);
return isActive;
}
return true;
}
};
@ -1261,10 +1476,12 @@ g_SelectionPanels.Upgrade = {
function initSelectionPanels()
{
const unitBarterPanel = Engine.GetGUIObjectByName("unitBarterPanel");
if (BarterButtonManager.IsAvailable(unitBarterPanel))
g_SelectionPanelBarterButtonManager = new BarterButtonManager(unitBarterPanel);
for (const panel in g_SelectionPanels)
g_SelectionPanels[panel].init?.();
}
/**

View file

@ -1,17 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<object name="unitResearchPanel"
size="6 100%-82 100% 100%"
size="6 45 100% 100%"
>
<object>
<repeat count="20">
<object name="unitResearchButton[n]" hidden="true" style="iconButton" type="button" size="0 0 38 38" tooltip_style="sessionToolTipBottom">
<object name="unitResearchIcon[n]" type="image" ghost="true" size="3 3 35 35"/>
<object name="unitResearchUnchosenIcon[n]" type="image" hidden="true" ghost="true" size="3 3 35 35" sprite="stretched:session/icons/tech_pair_would_be_unavailable.png"/>
</object>
</repeat>
<repeat count="10">
<object name="unitResearchPair[n]" hidden="true" size="0 0 38 76">
<object name="unitResearchPairIcon[n]" type="image" ghost="true" size="8 30 30 46" sprite="stretched:session/icons/vertical_pair.png"/>
<object name="unitResearchAffectsIcon[n]" type="image" z="60" hidden="true" ghost="true" size="4 -10 34 6"/>
<!-- Positioned for the case that the second tech in the pair is located below it. -->
<object name="unitResearchVerticalPairIcon[n]" type="image" z="60" ghost="true" hidden="true" size="8 30 30 46"/>
<!-- Positioned for the case that the second tech in the pair is located to the right of it. -->
<object name="unitResearchHorizontalPairIcon[n]" type="image" z="60" ghost="true" hidden="true" size="30 8 46 30"/>
</object>
</repeat>
</object>

View file

@ -95,7 +95,7 @@
<!-- Commands Panel (right). -->
<object name="unitCommands"
size="50%+110 100%-166 50%+512 100%"
size="50%+110 100%-173 50%+512 100%"
sprite="unitCommandsPanel"
type="image"
z="20"

View file

@ -550,6 +550,11 @@
<image backcolor="0 0 0 185"/>
</sprite>
<sprite name="ResearchPanelSeparator">
<image backcolor="57 55 30"/>
<image backcolor="89 77 23" size="0 1 100% 2"/>
</sprite>
<!-- ================================ ================================ -->
<!-- Unit portrait -->
<!-- ================================ ================================ -->

View file

@ -22,12 +22,12 @@ var g_unitPanelButtons = {
* Will wrap around to subsequent rows if the index
* is larger than rowLength.
*/
function setPanelObjectPosition(object, index, rowLength, vMargin = 1, hMargin = 1)
function setPanelObjectPosition(object, index, rowLength, vMargin = 1, hMargin = 1, vOffset = 0, hOffset = 0)
{
const oWidth = object.size.right - object.size.left;
const oHeight = object.size.bottom - object.size.top;
const left = (index % rowLength) * (oWidth + vMargin);
const top = (Math.floor(index / rowLength)) * (oHeight + hMargin);
const left = (index % rowLength) * (oWidth + hMargin) + hOffset;
const top = (Math.floor(index / rowLength)) * (oHeight + vMargin) + vOffset;
Object.assign(object.size, {
// horizontal position
@ -55,15 +55,11 @@ function setupUnitPanel(guiName, unitEntStates, playerState)
return;
}
const items = g_SelectionPanels[guiName].getItems(unitEntStates);
if (!items || !items.length)
return;
const items = g_SelectionPanels[guiName].getItems(unitEntStates) || [];
const numberOfItems = Math.min(items.length, g_SelectionPanels[guiName].getMaxNumberOfItems());
const rowLength = g_SelectionPanels[guiName].rowLength || 8;
if (g_SelectionPanels[guiName].resizePanel)
if (numberOfItems && g_SelectionPanels[guiName].resizePanel)
g_SelectionPanels[guiName].resizePanel(numberOfItems, rowLength);
for (let i = 0; i < numberOfItems; ++i)
@ -105,10 +101,13 @@ function setupUnitPanel(guiName, unitEntStates, playerState)
if (g_SelectionPanels[guiName].hideItem)
g_SelectionPanels[guiName].hideItem(i, rowLength);
else
Engine.GetGUIObjectByName("unit" + guiName + "Button[" + i + "]").hidden = true;
{
const button = Engine.TryGetGUIObjectByName("unit" + guiName + "Button[" + i + "]");
if (button) button.hidden = true;
}
g_unitPanelButtons[guiName] = numberOfItems;
g_SelectionPanels[guiName].used = true;
g_SelectionPanels[guiName].used = numberOfItems > 0;
}
/**
@ -126,7 +125,10 @@ function setupUnitPanel(guiName, unitEntStates, playerState)
function updateUnitCommands(entStates, supplementalDetailsPanel, commandsPanel)
{
for (const panel in g_SelectionPanels)
{
g_SelectionPanels[panel].used = false;
g_SelectionPanels[panel].reset?.();
}
// Get player state to check some constraints
// e.g. presence of a hero or build limits.
@ -140,11 +142,9 @@ function updateUnitCommands(entStates, supplementalDetailsPanel, commandsPanel)
{
for (const guiName of g_PanelsOrder)
{
if (g_SelectionPanels[guiName].conflictsWith &&
g_SelectionPanels[guiName].conflictsWith.some(p => g_SelectionPanels[p].used))
continue;
setupUnitPanel(guiName, entStates, playerStates[entStates[0].player]);
if (!g_SelectionPanels[guiName].conflictsWith ||
g_SelectionPanels[guiName].conflictsWith.every(p => !g_SelectionPanels[p].used))
setupUnitPanel(guiName, entStates, playerStates[entStates[0].player]);
}
supplementalDetailsPanel.hidden = false;

View file

@ -21,5 +21,6 @@
{ "value": "Health/Max", "multiply": 1.25 }
],
"affects": ["Champion Infantry Spearman"],
"placeBelow": "{AffectedUnit}",
"soundComplete": "interface/alarm/alarm_upgradearmory.xml"
}

View file

@ -21,5 +21,6 @@
{ "value": "Loot/food", "add": 5 }
],
"affects": ["Mercenary Swordsman"],
"placeBelow": "{AffectedUnit}",
"soundComplete": "interface/alarm/alarm_upgradearmory.xml"
}

View file

@ -23,5 +23,6 @@
{ "value": "Cost/BuildTime", "multiply": 0.5 }
],
"affects": ["Minister"],
"placeBelow": "{AffectedUnit}",
"soundComplete": "interface/alarm/alarm_upgradearmory.xml"
}

View file

@ -23,5 +23,6 @@
{ "value": "Health/Max", "multiply": 1.5 }
],
"affects": ["Minister"],
"placeBelow": "{AffectedUnit}",
"soundComplete": "interface/alarm/alarm_upgradearmory.xml"
}

View file

@ -16,5 +16,6 @@
{ "value": "Loot/metal", "replace": 0 }
],
"affects": ["Healer !Champion !Hero"],
"placeBelow": "{AffectedUnit}",
"soundComplete": "interface/alarm/alarm_upgradearmory.xml"
}

View file

@ -19,5 +19,6 @@
{ "value": "Cost/BuildTime", "multiply": 0.8 }
],
"affects": ["Crossbowman"],
"placeBelow": "{AffectedUnit}",
"soundComplete": "interface/alarm/alarm_upgradearmory.xml"
}

View file

@ -15,5 +15,6 @@
{ "value": "ResourceGatherer/Capacities/food", "add": 20 }
],
"affects": ["FishingBoat"],
"placeBelow": "{AffectedUnit}",
"soundComplete": "interface/alarm/alarm_upgradearmory.xml"
}

View file

@ -13,5 +13,6 @@
{ "value": "ResourceGatherer/Rates/food.fish", "multiply": 1.3 }
],
"affects": ["FishingBoat"],
"placeBelow": "{AffectedUnit}",
"soundComplete": "interface/alarm/alarm_upgradearmory.xml"
}

View file

@ -18,5 +18,6 @@
"modifications": [
{ "value": "Health/Max", "multiply": 1.5, "affects": "Healer" }
],
"placeBelow": "Champion Healer",
"soundComplete": "interface/alarm/alarm_upgradearmory.xml"
}

View file

@ -22,5 +22,6 @@
{ "value": "Vision/Range", "add": 5 }
],
"affects": ["Healer"],
"placeBelow": "{AffectedUnit}",
"soundComplete": "interface/alarm/alarm_upgradearmory.xml"
}

View file

@ -19,5 +19,6 @@
{ "value": "Vision/Range", "add": 5 }
],
"affects": ["Healer"],
"placeBelow": "{AffectedUnit}",
"soundComplete": "interface/alarm/alarm_upgradearmory.xml"
}

View file

@ -21,5 +21,6 @@
{ "value": "Heal/Interval", "multiply": 0.8 }
],
"affects": ["Healer"],
"placeBelow": "{AffectedUnit}",
"soundComplete": "interface/alarm/alarm_upgradearmory.xml"
}

View file

@ -22,5 +22,6 @@
{ "value": "Heal/Interval", "multiply": 0.8 }
],
"affects": ["Healer"],
"placeBelow": "{AffectedUnit}",
"soundComplete": "interface/alarm/alarm_upgradearmory.xml"
}

View file

@ -13,5 +13,6 @@
{ "value": "Health/Max", "multiply": 2 }
],
"affects": ["Civilian"],
"placeBelow": "{AffectedUnit}",
"soundComplete": "interface/alarm/alarm_upgradearmory.xml"
}

View file

@ -18,5 +18,6 @@
{ "value": "ResourceGatherer/Rates/food.grain", "multiply": 2 }
],
"affects": ["Infantry Javelineer"],
"placeBelow": "{AffectedUnit}",
"soundComplete": "interface/alarm/alarm_upgradearmory.xml"
}

View file

@ -26,5 +26,6 @@
{ "value": "Health/Max", "multiply": 1.1 }
],
"affects": ["Infantry Spearman !Hero"],
"placeBelow": "{AffectedUnit}",
"soundComplete": "interface/alarm/alarm_upgradearmory.xml"
}

View file

@ -19,5 +19,6 @@
{ "value": "Cost/BuildTime", "multiply": 0.5 }
],
"affects": ["Immortal"],
"placeBelow": "{AffectedUnit}",
"soundComplete": "interface/alarm/alarm_upgradearmory.xml"
}

View file

@ -20,5 +20,6 @@
{ "value": "Attack/Melee/Damage/Pierce", "multiply": 1.1, "affects": "Champion" },
{ "value": "Cost/BuildTime", "multiply": 1.3, "affects": "Citizen Infantry Javelineer" }
],
"placeBelow": "Champion Spearman",
"soundComplete": "interface/alarm/alarm_upgradearmory.xml"
}

View file

@ -29,5 +29,6 @@
{ "value": "Health/Max", "multiply": 1.1 }
],
"affects": ["Champion Cavalry Spearman"],
"placeBelow": "{AffectedUnit}",
"soundComplete": "interface/alarm/alarm_upgradearmory.xml"
}

View file

@ -11,5 +11,6 @@
"requirementsTooltip": "Unlocked in City Phase.",
"icon": "helmet_corinthian_crest.png",
"tooltip": "Unlock the Champion Infantry Swordsman.",
"placeBelow": "{UnlockedUnit}",
"soundComplete": "interface/alarm/alarm_upgradearmory.xml"
}

View file

@ -26,5 +26,6 @@
{ "value": "Attack/Ranged/Projectile/Spread", "multiply": 0.8 }
],
"affects": ["BoltShooter"],
"placeBelow": "{AffectedUnit}",
"soundComplete": "interface/alarm/alarm_upgradearmory.xml"
}

View file

@ -18,5 +18,6 @@
{ "value": "Promotion/RequiredXp", "replace": 0 }
],
"affects": ["Champion Infantry"],
"placeBelow": "{AffectedUnit}",
"soundComplete": "interface/alarm/alarm_upgradearmory.xml"
}

View file

@ -13,5 +13,6 @@
{ "value": "Health/Max", "multiply": 1.5 }
],
"affects": ["Trader !Ship"],
"placeBelow": "{AffectedUnit}",
"soundComplete": "interface/alarm/alarm_upgradearmory.xml"
}

View file

@ -11,5 +11,6 @@
"requirementsTooltip": "Unlocked in City Phase.",
"icon": "helmet_corinthian_crest.png",
"tooltip": "Unlock the Champion Infantry Pikeman.",
"placeBelow": "{UnlockedUnit}",
"soundComplete": "interface/alarm/alarm_upgradearmory.xml"
}

View file

@ -19,5 +19,6 @@
{ "value": "UnitMotion/WalkSpeed", "multiply": 1.1 }
],
"affects": ["Champion Melee Infantry !Hero"],
"placeBelow": "{AffectedUnit}",
"soundComplete": "interface/alarm/alarm_upgradearmory.xml"
}

View file

@ -20,5 +20,6 @@
"icon": "helmet_corinthian_crest.png",
"researchTime": 60,
"tooltip": "Unlock Champion Cavalry at the Stable.",
"placeBelow": "{UnlockedUnit}",
"soundComplete": "interface/alarm/alarm_upgradearmory.xml"
}

View file

@ -21,5 +21,6 @@
"icon": "helmet_corinthian_crest.png",
"researchTime": 60,
"tooltip": "Unlock Champion Chariots at the Stable.",
"placeBelow": "{UnlockedUnit}",
"soundComplete": "interface/alarm/alarm_upgradearmory.xml"
}

View file

@ -24,5 +24,6 @@
"icon": "helmet_corinthian_crest.png",
"researchTime": 60,
"tooltip": "Unlock Champions Infantry at the Barracks.",
"placeBelow": "{UnlockedUnit}",
"soundComplete": "interface/alarm/alarm_upgradearmory.xml"
}

View file

@ -24,5 +24,6 @@
"icon": "wives_festival.png",
"researchTime": 60,
"tooltip": "Unlock the ability to train Civilians from houses.",
"placeBelow": "{UnlockedUnit}",
"soundComplete": "interface/alarm/alarm_upgradearmory.xml"
}

View file

@ -28,5 +28,6 @@
"icon": "wives_festival_african.png",
"researchTime": 60,
"tooltip": "Unlock the ability to train Civilians from houses.",
"placeBelow": "{UnlockedUnit}",
"soundComplete": "interface/alarm/alarm_upgradearmory.xml"
}

View file

@ -23,5 +23,6 @@
"icon": "wives_festival.png",
"researchTime": 40,
"tooltip": "Unlock the ability to train women from houses.",
"placeBelow": "{UnlockedUnit}",
"soundComplete": "interface/alarm/alarm_upgradearmory.xml"
}

View file

@ -14,5 +14,6 @@
"icon": "helmet_corinthian_bronze_old.png",
"researchTime": 60,
"tooltip": "Unlock the ability to train Spearman Neodamodes at the Barracks.",
"placeBelow": "{UnlockedUnit}",
"soundComplete": "interface/alarm/alarm_upgradearmory.xml"
}

View file

@ -19,5 +19,6 @@
{ "value": "Promotion/RequiredXp", "replace": 0, "affects": "Wagon" },
{ "value": "Population/Bonus", "add": 10, "affects": "Colony" }
],
"placeBelow": "{AffectedUnit}",
"soundComplete": "interface/alarm/alarm_upgradearmory.xml"
}

View file

@ -28,5 +28,6 @@
{ "value": "Attack/Ranged/MaxRange", "multiply": 1.1 }
],
"affects": ["ArrowShip"],
"placeBelow": "{AffectedUnit}",
"soundComplete": "interface/alarm/alarm_upgradearmory.xml"
}

View file

@ -30,5 +30,6 @@
{ "value": "Health/Max", "multiply": 1.25 }
],
"affects": ["Ignited"],
"placeBelow": "{AffectedUnit}",
"soundComplete": "interface/alarm/alarm_upgradearmory.xml"
}

View file

@ -34,5 +34,6 @@
{ "value": "Attack/Melee/Damage/Hack", "multiply": 1.3 }
],
"affects": ["NavalRam"],
"placeBelow": "{AffectedUnit}",
"soundComplete": "interface/alarm/alarm_upgradearmory.xml"
}

View file

@ -30,5 +30,6 @@
{ "value": "Attack/Ranged/MaxRange", "multiply": 1.2 }
],
"affects": ["NavalSiege"],
"placeBelow": "{AffectedUnit}",
"soundComplete": "interface/alarm/alarm_upgradearmory.xml"
}