Add a tutorial panel for explaining the GUI

This patch adds a new type of tutorial steps called "GUI explanation",
with a corresponding GUI panel. The purpose of it is to explain what a
certain GUI element does. To make use of it the trigger script has to
specify the target GUI object's name as well as the side on which to
place the explanation panel relative to the target itself. The panel
then highlights the target object by fading everything else out with
black and also uses an arrow to point to it. Whilever the target GUI
object is hidden, the panel hides the background fade too and shows a
warning message.
Unlike for the other steps, the TutorialManager does not hide the
previously active panel when showing a GUI explanation, but instead
only disables it, since it could contain relevant information and the
GUI explanation panel is visibly placed "above" all other panels (in the
Z axis).
This commit is contained in:
Vantha 2026-03-29 20:55:58 +02:00
parent 9a867d4e81
commit 9d4055364d
15 changed files with 378 additions and 20 deletions

View file

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

View file

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

View file

@ -18,7 +18,7 @@
<object name="gameStateNotifications"
type="text"
ghost="true"
z="199"
z="200"
size="100%-110 40 100%-110 40"
font="mono-stroke-10"
textcolor="255 219 77"
@ -36,7 +36,7 @@
<object name="dataCounter"
type="text"
ghost="true"
z="199"
z="200"
size="100%-100 40 100%-5 54"
font="mono-10"
textcolor="white"
@ -58,7 +58,7 @@
<object name="glbWaterMark"
hidden="true"
hotkey="screenshot.watermark"
z="200"
z="300"
>
<action on="Press">
this.hidden = !this.hidden;

View file

@ -4,7 +4,7 @@
type="text"
hidden="true"
ghost="true"
z="199"
z="160"
size="100%-300 60 100%-110 80"
font="mono-10"
textcolor="white"

View file

@ -112,7 +112,7 @@
<!-- Status Effects icons -->
<object name="statusEffectsIcons" size="100%-20 4 100%-4 100%">
<repeat count="5">
<object type="image" size="0 0 16 16" z="200" tooltip_style="sessionToolTip"/>
<object type="image" size="0 0 16 16" z="100" tooltip_style="sessionToolTip"/>
</repeat>
</object>
</object>

View file

@ -532,6 +532,49 @@
<image backcolor="red" size="100%-1 0 100% 100%"/>
</sprite>
<!-- ================================ ================================ -->
<!-- Tutorial -->
<!-- ================================ ================================ -->
<sprite name="GuiExplanationPanel">
<image texture="session/hud_panels.png"/>
<!-- top edge -->
<image backcolor="41 32 11" size="0 0 100% 4"/>
<image backcolor="164 133 57" size="2 2 100%-2 3"/>
<image backcolor="221 180 87" size="1 1 100%-1 2"/>
<!-- bottom edge -->
<image backcolor="41 32 11" size="0 100%-4 100% 100%"/>
<image backcolor="164 133 57" size="2 100%-3 100%-2 100%-2"/>
<image backcolor="221 180 87" size="1 100%-2 100%-1 100%-1"/>
<!-- left edge -->
<image backcolor="41 32 11" size="0 4 4 100%-4"/>
<image backcolor="164 133 57" size="2 3 3 100%-3"/>
<image backcolor="221 180 87" size="1 2 2 100%-2"/>
<!-- right edge -->
<image backcolor="41 32 11" size="100%-4 4 100% 100%-4"/>
<image backcolor="164 133 57" size="100%-3 3 100%-2 100%-3"/>
<image backcolor="221 180 87" size="100%-2 2 100%-1 100%-2"/>
</sprite>
<sprite name="GoldenArrowDown">
<image texture="session/golden-arrow_down.png"/>
</sprite>
<sprite name="GoldenArrowUp">
<image texture="session/golden-arrow_down.png" texture_size="0 100% 100% 0"/>
</sprite>
<sprite name="GoldenArrowLeft">
<image texture="session/golden-arrow_left.png"/>
</sprite>
<sprite name="GoldernArrowRight">
<image texture="session/golden-arrow_left.png" texture_size="100% 0 0 100%"/>
</sprite>
<!-- ================================ ================================ -->
<!-- Misc -->
<!-- ================================ ================================ -->

View file

@ -254,6 +254,25 @@
text_valign="center"
/>
<style name="TutorialExplanationTitle"
buffer_zone="10"
font="sans-bold-18"
textcolor="gold"
text_align="center"
text_valign="top"
/>
<style name="TutorialExplanationText"
buffer_zone="10"
font="sans-16"
sprite="ModernFade"
textcolor="white"
scrollbar="true"
scrollbar_style="ModernScrollBar"
text_align="center"
text_valign="top"
/>
<!-- ================================ ================================ -->
<!-- Icon Styles -->
<!-- ================================ ================================ -->

View file

@ -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)],
[TUTORIAL_STEP_TYPE.INFO, new InfoPanel(this)]
[TUTORIAL_STEP_TYPE.INFO, new InfoPanel(this)],
[TUTORIAL_STEP_TYPE.GUI_EXPLANATION, new GuiExplanationPanel(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]);

View file

@ -11,7 +11,7 @@ class TutorialPanel
hint;
button;
constructor(name, manager)
constructor(name, continueAction)
{
this.panel = Engine.GetGUIObjectByName(name);
this.text = Engine.GetGUIObjectByName(name + "Text");
@ -19,7 +19,7 @@ class TutorialPanel
this.button = Engine.GetGUIObjectByName(name + "Button");
this.button.caption = this.ButtonCaptions.Continue;
this.button.onPress = manager.continue.bind(manager);
this.button.onPress = continueAction;
}
setVisible(visible)
@ -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);

View file

@ -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(manager)
{
super("guiExplanationPanel", () =>
{
this.resetTargetObjectZ();
this.targetObject = null;
manager.continue();
});
// 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;

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<object name="guiExplanationPanel" type="image" sprite="GuiExplanationPanel">
<object name="guiExplanationPanelFade" type="image" sprite="color:0 0 0 125" ghost="true" absolute="true"/>
<object name="guiExplanationPanelText" type="text" size="18 18 100%-18 100%-54" style="TutorialExplanationText"/>
<object name="guiExplanationPanelButton" type="button" style="ModernButtonRed" size="100%-170 100%-42 100%-30 100%-14"/>
<object name="guiExplanationPanelHint" type="text" style="ModernLeftLabelText" size="25 100%-48 100%-190 100%-8"/>
<object name="guiExplanationPanelArrow" type="image" absolute="true"/>
</object>

View file

@ -14,7 +14,7 @@ class InfoPanel extends TutorialPanel
constructor(manager)
{
super("infoPanel", manager);
super("infoPanel", manager.continue.bind(manager));
}
displayStep(step)

View file

@ -8,7 +8,7 @@ class InstructionPanel extends TutorialPanel
constructor(manager)
{
super("instructionPanel", manager);
super("instructionPanel", manager.continue.bind(manager));
}
displayStep(step)

View file

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

View file

@ -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.")
},