diff --git a/binaries/data/mods/public/art/textures/ui/session/golden-arrow_down.png b/binaries/data/mods/public/art/textures/ui/session/golden-arrow_down.png
new file mode 100644
index 0000000000..4b8744247c
--- /dev/null
+++ b/binaries/data/mods/public/art/textures/ui/session/golden-arrow_down.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3d1a87bfd33e5c55c8bc28eac1985f17fe5d4c51d853b4984f85051ce96a62d8
+size 2598
diff --git a/binaries/data/mods/public/art/textures/ui/session/golden-arrow_left.png b/binaries/data/mods/public/art/textures/ui/session/golden-arrow_left.png
new file mode 100644
index 0000000000..a14f2b29b8
--- /dev/null
+++ b/binaries/data/mods/public/art/textures/ui/session/golden-arrow_left.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:5ca1fbd89c75157abb88e88d047273c93a61ff085c1eb5311a36ce07b4133590
+size 2611
diff --git a/binaries/data/mods/public/gui/common/global.xml b/binaries/data/mods/public/gui/common/global.xml
index 7113cbef71..042c396c87 100644
--- a/binaries/data/mods/public/gui/common/global.xml
+++ b/binaries/data/mods/public/gui/common/global.xml
@@ -18,7 +18,7 @@
diff --git a/binaries/data/mods/public/gui/session/sprites.xml b/binaries/data/mods/public/gui/session/sprites.xml
index 632c7df729..0c52a68d49 100644
--- a/binaries/data/mods/public/gui/session/sprites.xml
+++ b/binaries/data/mods/public/gui/session/sprites.xml
@@ -532,6 +532,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/binaries/data/mods/public/gui/session/styles.xml b/binaries/data/mods/public/gui/session/styles.xml
index 8c724adae7..8b197a5367 100644
--- a/binaries/data/mods/public/gui/session/styles.xml
+++ b/binaries/data/mods/public/gui/session/styles.xml
@@ -254,6 +254,25 @@
text_valign="center"
/>
+
+
+
+
diff --git a/binaries/data/mods/public/gui/session/tutorial/TutorialManager.js b/binaries/data/mods/public/gui/session/tutorial/TutorialManager.js
index e8187301ac..60df90a1a3 100644
--- a/binaries/data/mods/public/gui/session/tutorial/TutorialManager.js
+++ b/binaries/data/mods/public/gui/session/tutorial/TutorialManager.js
@@ -1,7 +1,8 @@
// Needs to be kept in sync with the one in maps/scripts/Tutorial.js
const TUTORIAL_STEP_TYPE = deepfreeze({
"INSTRUCTION": 1,
- "INFO": 2
+ "INFO": 2,
+ "GUI_EXPLANATION": 3
});
/**
@@ -15,7 +16,8 @@ class TutorialManager
panels = new Map([
[TUTORIAL_STEP_TYPE.INSTRUCTION, new InstructionPanel(this.continue.bind(this))],
- [TUTORIAL_STEP_TYPE.INFO, new InfoPanel(this.continue.bind(this))]
+ [TUTORIAL_STEP_TYPE.INFO, new InfoPanel(this.continue.bind(this))],
+ [TUTORIAL_STEP_TYPE.GUI_EXPLANATION, new GuiExplanationPanel(this.continue.bind(this))]
]);
displayedSteps = []; // All steps that have already been displayed, in the form of [stepType, panelData]
@@ -117,8 +119,18 @@ class TutorialManager
if (!this.panels.has(stepType))
throw new Error("Failed to display tutorial step: Unkown step type: " + stepType);
- this.panels.forEach((panel, type) => panel.setVisible(type == stepType));
+ // Explicitly don't hide the previously active panel if the new step is a GUI explanation. It's displayed
+ // "on top" of everything else.
+ if (stepType == TUTORIAL_STEP_TYPE.GUI_EXPLANATION)
+ {
+ this.panels.get(TUTORIAL_STEP_TYPE.GUI_EXPLANATION).setVisible(true);
+ this.activePanel.setEnabled(false);
+ }
+ else
+ this.panels.forEach((panel, type) => panel.setVisible(type == stepType));
+
this.activePanel = this.panels.get(stepType);
+ this.activePanel.setEnabled(true);
this.activePanel.displayStep(panelData);
this.displayedSteps.push([stepType, panelData]);
diff --git a/binaries/data/mods/public/gui/session/tutorial/TutorialPanel.js b/binaries/data/mods/public/gui/session/tutorial/TutorialPanel.js
index 4a47299b30..43b6ddf6d9 100644
--- a/binaries/data/mods/public/gui/session/tutorial/TutorialPanel.js
+++ b/binaries/data/mods/public/gui/session/tutorial/TutorialPanel.js
@@ -27,6 +27,11 @@ class TutorialPanel
this.panel.hidden = !visible;
}
+ setEnabled(enabled)
+ {
+ this.button.enabled = enabled;
+ }
+
displayWarning(warning)
{
this.hint.caption = coloredText(warning, this.WarningColor);
diff --git a/binaries/data/mods/public/gui/session/tutorial/panels/GuiExplanationPanel.js b/binaries/data/mods/public/gui/session/tutorial/panels/GuiExplanationPanel.js
new file mode 100644
index 0000000000..a4b83348b3
--- /dev/null
+++ b/binaries/data/mods/public/gui/session/tutorial/panels/GuiExplanationPanel.js
@@ -0,0 +1,249 @@
+/**
+ * This class manages a tutorial panel meant to explain GUI elements (what they show or do) to the player, like
+ * "The resource counters at the top show how many resources you have ...".
+ * The explanations consist of a title and text. It also highlights the target GUI objects by fading out everything else
+ * with black and uses an arrow to point to them directly.
+ * If the target object is hidden the panel is still shown (pointing to nothing), but a warning displayed.
+ */
+class GuiExplanationPanel extends TutorialPanel
+{
+ panel = Engine.GetGUIObjectByName("guiExplanationPanel");
+ backgroundFade = Engine.GetGUIObjectByName("guiExplanationPanelFade");
+ arrow = Engine.GetGUIObjectByName("guiExplanationPanelArrow");
+
+ currentStep;
+ targetObject;
+ wasTargetHidden = false;
+
+ constructor(continueCallback)
+ {
+ super("guiExplanationPanel", () =>
+ {
+ this.resetTargetObjectZ();
+ this.targetObject = null;
+ continueCallback();
+ });
+
+ // Place the panel in front of all other session objects.
+ this.panel.z = this.OverlayZValue + 10;
+ // Place the black overlay behind the panel.
+ this.backgroundFade.z = this.OverlayZValue - 10;
+
+ // We need to set the arrow's Z value explicitly since it has 'absolute' set to true, which makes it not inherit
+ // the parent's value by default.
+ this.arrow.z = this.OverlayZValue;
+
+ // Initialize the size to a default, with all relative values set to 0.
+ // This is necessary for the custom sizing later.
+ this.panel.size = { "right": this.PanelWidth, "bottom": this.MaxPanelHeight };
+ this.arrow.size = { "right": this.ArrowScale * 2, "bottom": this.ArrowScale };
+
+ this.panel.onWindowResized = this.updatePanelSize.bind(this);
+ this.panel.onTick = this.updateContent.bind(this);
+ }
+
+ setVisible(visible)
+ {
+ super.setVisible(visible);
+ if (!visible)
+ this.resetTargetObjectZ();
+ }
+
+ resetTargetObjectZ()
+ {
+ if (this.targetObject)
+ this.targetObject.z = this.targetOriginalZ;
+ }
+
+ displayStep(step)
+ {
+ super.displayStep(step);
+
+ this.resetTargetObjectZ();
+
+ if (!["left", "top", "right", "bottom"].includes(step.side))
+ throw new Error("GuiExplanationPanel: Invalid or no side specified:" + step.side + ". It must be 'left', 'top', 'right', or 'bottom'.");
+
+ this.targetObject = Engine.TryGetGUIObjectByName(step.targetObject);
+ if (!this.targetObject)
+ throw new Error("GuiExplanationPanel: Non-existing target GUI object specified: '" + step.targetObject + "'.");
+
+ this.currentStep = step;
+
+ // This results in the target's grandchildren taking higher Z values than this panel (since they get their parent's value + 10).
+ // But that's ok, since they should never overlap anyway.
+ this.targetOriginalZ = this.targetObject.z;
+ this.targetObject.z = this.OverlayZValue;
+
+ this.arrow.sprite = this.ArrowSprites[step.side];
+
+ this.wasTargetHidden = undefined;
+ this.updateContent();
+ }
+
+ /**
+ * Check the visibility of the target object and update the text and background fade accordingly, if necessary.
+ */
+ updateContent()
+ {
+ if (!this.targetObject)
+ return;
+
+ const displayTextAndTitle = (title, text) =>
+ {
+ this.text.caption = (title ? setStringTags(title, this.TitleTags) + setStringTags("\n\n", { "font": "sans-3" }) : "") + text;
+ this.updatePanelSize();
+ };
+
+ if (this.targetObject.hidden && !this.wasTargetHidden)
+ {
+ this.backgroundFade.hidden = true;
+ displayTextAndTitle(this.currentStep.title, this.HiddenWarning);
+ this.wasTargetHidden = true;
+ }
+ else if (!this.targetObject.hidden && (this.wasTargetHidden == undefined || this.wasTargetHidden))
+ {
+ this.backgroundFade.hidden = false;
+ displayTextAndTitle(this.currentStep.title, this.currentStep.text);
+ this.wasTargetHidden = false;
+ }
+ }
+
+ /**
+ * Place the panel next to the target object on the desired side relative to it.
+ */
+ updatePanelSize()
+ {
+ if (!this.targetObject)
+ return;
+
+ const panelHeight = Math.min(this.MaxPanelHeight, this.text.size.top + this.text.getTextSize().height - this.text.size.bottom);
+ const targetComputedSize = this.targetObject.getComputedSize();
+ const targetHorizontalCenter = (targetComputedSize.left + targetComputedSize.right) / 2;
+ const targetVerticalCenter = (targetComputedSize.top + targetComputedSize.bottom) / 2;
+
+ // Don't perfectly center the panel on the target object, instead shift it to one side a bit.
+ // That looks better.
+ const panelPlacementRatio = 0.3;
+
+ if (this.currentStep.side == "top" || this.currentStep.side == "bottom")
+ {
+ this.panel.size.left = targetHorizontalCenter - panelPlacementRatio * this.PanelWidth;
+ this.panel.size.right = targetHorizontalCenter + (1 - panelPlacementRatio) * this.PanelWidth;
+ this.arrow.size.left = targetHorizontalCenter - this.ArrowScale;
+ this.arrow.size.right = targetHorizontalCenter + this.ArrowScale;
+
+ if (this.currentStep.side == "top")
+ {
+ this.panel.size.bottom = targetComputedSize.top - this.PanelDistanceFromTarget;
+ this.panel.size.top = this.panel.size.bottom - panelHeight;
+ this.arrow.size.bottom = targetComputedSize.top - this.ArrowDistanceFromTarget;
+ this.arrow.size.top = this.arrow.size.bottom - this.ArrowScale;
+ }
+ else
+ {
+ this.panel.size.top = targetComputedSize.bottom + this.PanelDistanceFromTarget;
+ this.panel.size.bottom = this.panel.size.top + panelHeight;
+ this.arrow.size.top = targetComputedSize.bottom + this.ArrowDistanceFromTarget;
+ this.arrow.size.bottom = this.arrow.size.top + this.ArrowScale;
+ }
+ }
+ else
+ {
+ this.panel.size.top = targetVerticalCenter - panelPlacementRatio * panelHeight;
+ this.panel.size.bottom = targetVerticalCenter + (1 - panelPlacementRatio) * panelHeight;
+ this.arrow.size.top = targetVerticalCenter - this.ArrowScale;
+ this.arrow.size.bottom = targetVerticalCenter + this.ArrowScale;
+
+ if (this.currentStep.side == "left")
+ {
+ this.panel.size.right = targetComputedSize.left - this.PanelDistanceFromTarget;
+ this.panel.size.left = this.panel.size.right - this.PanelWidth;
+ this.arrow.size.right = targetComputedSize.left - this.ArrowDistanceFromTarget;
+ this.arrow.size.left = this.arrow.size.right - this.ArrowScale;
+ }
+ else
+ {
+ this.panel.size.left = targetComputedSize.right + this.PanelDistanceFromTarget;
+ this.panel.size.right = this.panel.size.left + this.PanelWidth;
+ this.arrow.size.left = targetComputedSize.right + this.ArrowDistanceFromTarget;
+ this.arrow.size.right = this.arrow.size.left + this.ArrowScale;
+ }
+ }
+
+ let root = this.panel;
+ while (root.parent) root = root.parent;
+ const screenSize = root.getComputedSize();
+
+ // Perform some clamping to ensure the panel is always fully visible on the screen.
+ if (this.panel.size.left < 0)
+ {
+ this.panel.size.right -= this.panel.size.left;
+ this.panel.size.left = 0;
+ }
+ else if (this.panel.size.right > screenSize.right)
+ {
+ this.panel.size.left -= this.panel.size.right - screenSize.right;
+ this.panel.size.right = screenSize.right;
+ }
+ if (this.panel.size.top < 0)
+ {
+ this.panel.size.bottom -= this.panel.size.top;
+ this.panel.size.top = 0;
+ }
+ else if (this.panel.size.bottom > screenSize.bottom)
+ {
+ this.panel.size.top -= this.panel.size.bottom - screenSize.bottom;
+ this.panel.size.bottom = screenSize.bottom;
+ }
+ }
+}
+
+/**
+ * Warning shown whilever the target objects is hidden.
+ */
+GuiExplanationPanel.prototype.HiddenWarning = coloredText(
+ translate("It is currently hidden. Follow the previous instructions for it to be shown again."),
+ "orange"
+);
+
+/**
+ * GUI tags applied to the shown title.
+ */
+GuiExplanationPanel.prototype.TitleTags = { "color": "gold", "font": "sans-bold-18" };
+
+/**
+ * Which sprite to use depending on the side that the description panel is placed on. The sprites only differ in rotation.
+ * E.g. if the panel is placed to the left of the target object, the arrow has to point to the right.
+ */
+GuiExplanationPanel.prototype.ArrowSprites = {
+ "left": "GoldenArrowRight",
+ "top": "GoldenArrowDown",
+ "right": "GoldenArrowLeft",
+ "bottom": "GoldenArrowUp"
+};
+
+GuiExplanationPanel.prototype.PanelWidth = 600;
+GuiExplanationPanel.prototype.MaxPanelHeight = 200;
+
+/**
+ * Margin between the target object and the panel itself. The arrow has to fit inside that gap.
+ */
+GuiExplanationPanel.prototype.PanelDistanceFromTarget = 40;
+
+/**
+ * Margin between the target object and the arrow.
+ */
+GuiExplanationPanel.prototype.ArrowDistanceFromTarget = 5;
+
+/**
+ * Greater than the z value of all other session GUI objects (except the watermark).
+ */
+GuiExplanationPanel.prototype.OverlayZValue = 250;
+
+/**
+ * The arrow is always twice as wide as it is long.
+ * Therefore, for vertical arrows (pointing up or down) this defines the height and half the width,
+ * while for horizontal arrows (pointing left or right) this defines the width and half the height.
+ */
+GuiExplanationPanel.prototype.ArrowScale = 32;
diff --git a/binaries/data/mods/public/gui/session/tutorial/panels/GuiExplanationPanel.xml b/binaries/data/mods/public/gui/session/tutorial/panels/GuiExplanationPanel.xml
new file mode 100644
index 0000000000..64db3e0bb9
--- /dev/null
+++ b/binaries/data/mods/public/gui/session/tutorial/panels/GuiExplanationPanel.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/binaries/data/mods/public/maps/scripts/Tutorial.js b/binaries/data/mods/public/maps/scripts/Tutorial.js
index 870115fba2..32681528e4 100644
--- a/binaries/data/mods/public/maps/scripts/Tutorial.js
+++ b/binaries/data/mods/public/maps/scripts/Tutorial.js
@@ -1,7 +1,8 @@
// Needs to be kept in sync with the one in gui/session/tutorial/Tutorial.js
var TUTORIAL_STEP_TYPE = deepfreeze({
"INSTRUCTION": 1,
- "INFO": 2
+ "INFO": 2,
+ "GUI_EXPLANATION": 3
});
Engine.RegisterGlobal("TUTORIAL_STEP_TYPE", TUTORIAL_STEP_TYPE);
diff --git a/binaries/data/mods/public/maps/tutorials/starting_economy_walkthrough.js b/binaries/data/mods/public/maps/tutorials/starting_economy_walkthrough.js
index 494789132e..0ddf643ed5 100644
--- a/binaries/data/mods/public/maps/tutorials/starting_economy_walkthrough.js
+++ b/binaries/data/mods/public/maps/tutorials/starting_economy_walkthrough.js
@@ -29,9 +29,10 @@ Trigger.prototype.tutorialSteps = [
}
},
{
- "type": TUTORIAL_STEP_TYPE.INFO,
+ "type": TUTORIAL_STEP_TYPE.GUI_EXPLANATION,
"panelData": {
- "appendable": true,
+ "targetObject": "unitCommands",
+ "side": "top",
"title": markForTranslation("Production Panel"),
"texts": [
markForTranslation("Now that the Civic Center is selected, you will notice that a production panel will appear on the lower right of your screen detailing the actions that the buildings supports. For the production panel, available actions are not masked in any color, while an icon masked in gray indicates that the action has not been unlocked and a red mask indicates that you do not have sufficient resources to perform that action. Additionally, you can hover the cursor over any icon to show a tooltip with more details."),
@@ -166,13 +167,25 @@ Trigger.prototype.tutorialSteps = [
this.NextStep();
}
},
+ {
+
+ "type": TUTORIAL_STEP_TYPE.GUI_EXPLANATION,
+ "panelData": {
+ "targetObject": "resourceCounts",
+ "side": "bottom",
+ "title": markForTranslation("Resource Supply"),
+ "text": markForTranslation("Direct your attention to the panel at the top of your screen. On the upper left, you will see your current resource supply (food, wood, stone, and metal). As each worker brings resources back to the Civic Center (or another dropsite), you will see the amount of the corresponding resource increase."),
+ "showContinueButton": true
+ },
+ "OnTrainingFinished": function(msg)
+ {
+ this.trainingFinished = true;
+ }
+ },
{
"type": TUTORIAL_STEP_TYPE.INFO,
"panelData": {
- "texts": [
- markForTranslation("While waiting, direct your attention to the panel at the top of your screen. On the upper left, you will see your current resource supply (food, wood, stone, and metal). As each worker brings resources back to the Civic Center (or another dropsite), you will see the amount of the corresponding resource increase."),
- markForTranslation("This is a very important concept to keep in mind: gathered resources have to be brought back to a dropsite to be accounted, and you should always try to minimize the distance between resource and nearest dropsite to improve your gathering efficiency.")
- ],
+ "text": markForTranslation("This is a very important concept to keep in mind: gathered resources have to be brought back to a dropsite to be accounted, and you should always try to minimize the distance between resource and nearest dropsite to improve your gathering efficiency."),
"showContinueButton": true
},
"OnTrainingFinished": function(msg)
@@ -270,8 +283,10 @@ Trigger.prototype.tutorialSteps = [
}
},
{
- "type": TUTORIAL_STEP_TYPE.INSTRUCTION,
+ "type": TUTORIAL_STEP_TYPE.GUI_EXPLANATION,
"panelData": {
+ "targetObject": "resourceCounts",
+ "side": "bottom",
"text": markForTranslation("The units should be ready soon.\nIn the meantime, direct your attention to your population count on the top panel. It is the fifth item from the left, after the resources. It would be prudent to keep an eye on it. It indicates your current population (including those being trained) and the current population limit, which is determined by your built structures."),
"hint": markForTranslation("Wait for the training of the Civilians to finish.")
},