diff --git a/Dalamud/Configuration/Internal/DalamudConfiguration.cs b/Dalamud/Configuration/Internal/DalamudConfiguration.cs
index d546dc517..ddcb26914 100644
--- a/Dalamud/Configuration/Internal/DalamudConfiguration.cs
+++ b/Dalamud/Configuration/Internal/DalamudConfiguration.cs
@@ -495,6 +495,16 @@ internal sealed class DalamudConfiguration : IInternalDisposableService
#pragma warning restore SA1516
#pragma warning restore SA1600
+ ///
+ /// Gets or sets a list of badge passwords used to unlock badges.
+ ///
+ public List UsedBadgePasswords { get; set; } = [];
+
+ ///
+ /// Gets or sets a value indicating whether badges should be shown on the title screen.
+ ///
+ public bool ShowBadgesOnTitleScreen { get; set; } = true;
+
///
/// Load a configuration from the provided path.
///
diff --git a/Dalamud/DalamudAsset.cs b/Dalamud/DalamudAsset.cs
index e234fbb4c..9c0f247ee 100644
--- a/Dalamud/DalamudAsset.cs
+++ b/Dalamud/DalamudAsset.cs
@@ -73,7 +73,7 @@ public enum DalamudAsset
[DalamudAsset(DalamudAssetPurpose.TextureFromPng)]
[DalamudAssetPath("UIRes", "troubleIcon.png")]
TroubleIcon = 1006,
-
+
///
/// : The plugin trouble icon overlay.
///
@@ -124,6 +124,13 @@ public enum DalamudAsset
[DalamudAssetPath("UIRes", "tsmShade.png")]
TitleScreenMenuShade = 1013,
+ ///
+ /// : Atlas containing badges.
+ ///
+ [DalamudAsset(DalamudAssetPurpose.TextureFromPng)]
+ [DalamudAssetPath("UIRes", "badgeAtlas.png")]
+ BadgeAtlas = 1015,
+
///
/// : Noto Sans CJK JP Medium.
///
diff --git a/Dalamud/Interface/DalamudWindowOpenKinds.cs b/Dalamud/Interface/DalamudWindowOpenKinds.cs
index 35d2825f7..891f9281a 100644
--- a/Dalamud/Interface/DalamudWindowOpenKinds.cs
+++ b/Dalamud/Interface/DalamudWindowOpenKinds.cs
@@ -56,6 +56,11 @@ public enum SettingsOpenKind
///
ServerInfoBar,
+ ///
+ /// Open to the "Badges" page.
+ ///
+ Badge,
+
///
/// Open to the "Experimental" page.
///
diff --git a/Dalamud/Interface/Internal/Badge/BadgeInfo.cs b/Dalamud/Interface/Internal/Badge/BadgeInfo.cs
new file mode 100644
index 000000000..0787f0658
--- /dev/null
+++ b/Dalamud/Interface/Internal/Badge/BadgeInfo.cs
@@ -0,0 +1,48 @@
+using System.Numerics;
+
+namespace Dalamud.Interface.Internal.Badge;
+
+///
+/// Represents information about a badge.
+///
+/// Name of the badge.
+/// Description of the badge.
+/// Icon index.
+/// Sha256 hash of the unlock password.
+/// How the badge is unlocked.
+internal record BadgeInfo(
+ Func Name,
+ Func Description,
+ int IconIndex,
+ string UnlockSha256,
+ BadgeUnlockMethod UnlockMethod)
+{
+ private const float BadgeWidth = 256;
+ private const float BadgeHeight = 256;
+ private const float BadgesPerRow = 2;
+
+ ///
+ /// Gets the UV coordinates for the badge icon in the atlas.
+ ///
+ /// Width of the atlas.
+ /// Height of the atlas.
+ /// UV coordinates.
+ public (Vector2 Uv0, Vector2 Uv1) GetIconUv(float atlasWidthPx, float atlasHeightPx)
+ {
+ // Calculate row and column from icon index
+ var col = this.IconIndex % (int)BadgesPerRow;
+ var row = this.IconIndex / (int)BadgesPerRow;
+
+ // Calculate pixel positions
+ var x0 = col * BadgeWidth;
+ var y0 = row * BadgeHeight;
+ var x1 = x0 + BadgeWidth;
+ var y1 = y0 + BadgeHeight;
+
+ // Convert to UV coordinates (0.0 to 1.0)
+ var uv0 = new Vector2(x0 / atlasWidthPx, y0 / atlasHeightPx);
+ var uv1 = new Vector2(x1 / atlasWidthPx, y1 / atlasHeightPx);
+
+ return (uv0, uv1);
+ }
+}
diff --git a/Dalamud/Interface/Internal/Badge/BadgeManager.cs b/Dalamud/Interface/Internal/Badge/BadgeManager.cs
new file mode 100644
index 000000000..9290d6cc8
--- /dev/null
+++ b/Dalamud/Interface/Internal/Badge/BadgeManager.cs
@@ -0,0 +1,85 @@
+using System.Collections.Generic;
+using System.Linq;
+
+using Dalamud.Configuration.Internal;
+
+namespace Dalamud.Interface.Internal.Badge;
+
+///
+/// Service responsible for managing user badges.
+///
+[ServiceManager.EarlyLoadedService]
+internal class BadgeManager : IServiceType
+{
+ private readonly DalamudConfiguration configuration;
+
+ private readonly List badges =
+ [
+ new(() => "Test Badge",
+ () => "Awarded for testing badges.",
+ 0,
+ "937e8d5fbb48bd4949536cd65b8d35c426b80d2f830c5c308e2cdec422ae2244",
+ BadgeUnlockMethod.User),
+
+ new(() => "Fundraiser #1 Donor",
+ () => "Awarded for participating in the first patch fundraiser.",
+ 1,
+ "56e752257bd0cbb2944f95cc7b3cb3d0db15091dd043f7a195ed37028d079322",
+ BadgeUnlockMethod.User)
+ ];
+
+ private readonly List unlockedBadgeIndices = [];
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Configuration to use.
+ [ServiceManager.ServiceConstructor]
+ public BadgeManager(DalamudConfiguration configuration)
+ {
+ this.configuration = configuration;
+
+ foreach (var usedBadge in this.configuration.UsedBadgePasswords)
+ {
+ this.TryUnlockBadge(usedBadge, BadgeUnlockMethod.Startup, out _);
+ }
+ }
+
+ ///
+ /// Gets the badges the user has unlocked.
+ ///
+ public IEnumerable UnlockedBadges
+ => this.badges.Where((_, index) => this.unlockedBadgeIndices.Contains(index));
+
+ ///
+ /// Unlock a badge with the given password and method.
+ ///
+ /// The password to unlock the badge with.
+ /// How we are unlocking this badge.
+ /// The badge that was unlocked, if the function returns true, null otherwise.
+ /// The unlocked badge, if one was unlocked by this call.
+ public bool TryUnlockBadge(string password, BadgeUnlockMethod method, out BadgeInfo unlockedBadge)
+ {
+ var sha256 = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(password));
+ var hashString = Convert.ToHexString(sha256);
+
+ foreach (var (idx, badge) in this.badges.Where(x => x.UnlockMethod == method || method == BadgeUnlockMethod.Startup).Index())
+ {
+ if (!this.unlockedBadgeIndices.Contains(idx) && badge.UnlockSha256.Equals(hashString, StringComparison.OrdinalIgnoreCase))
+ {
+ if (method != BadgeUnlockMethod.Startup)
+ {
+ this.configuration.UsedBadgePasswords.Add(password);
+ this.configuration.QueueSave();
+ }
+
+ this.unlockedBadgeIndices.Add(idx);
+ unlockedBadge = badge;
+ return true;
+ }
+ }
+
+ unlockedBadge = null!;
+ return false;
+ }
+}
diff --git a/Dalamud/Interface/Internal/Badge/BadgeUnlockMethod.cs b/Dalamud/Interface/Internal/Badge/BadgeUnlockMethod.cs
new file mode 100644
index 000000000..45828c097
--- /dev/null
+++ b/Dalamud/Interface/Internal/Badge/BadgeUnlockMethod.cs
@@ -0,0 +1,22 @@
+namespace Dalamud.Interface.Internal.Badge;
+
+///
+/// Method by which a badge can be unlocked.
+///
+internal enum BadgeUnlockMethod
+{
+ ///
+ /// Badge can be unlocked by the user by entering a password.
+ ///
+ User,
+
+ ///
+ /// Badge can be unlocked from Dalamud internal features.
+ ///
+ Internal,
+
+ ///
+ /// Badge is no longer obtainable and can only be unlocked from the configuration file.
+ ///
+ Startup,
+}
diff --git a/Dalamud/Interface/Internal/DalamudInterface.cs b/Dalamud/Interface/Internal/DalamudInterface.cs
index be4228a81..13e7ff3f7 100644
--- a/Dalamud/Interface/Internal/DalamudInterface.cs
+++ b/Dalamud/Interface/Internal/DalamudInterface.cs
@@ -19,6 +19,9 @@ using Dalamud.Game.Gui;
using Dalamud.Hooking;
using Dalamud.Interface.Animation.EasingFunctions;
using Dalamud.Interface.Colors;
+using Dalamud.Interface.ImGuiNotification;
+using Dalamud.Interface.ImGuiNotification.Internal;
+using Dalamud.Interface.Internal.Badge;
using Dalamud.Interface.Internal.Windows;
using Dalamud.Interface.Internal.Windows.Data;
using Dalamud.Interface.Internal.Windows.PluginInstaller;
@@ -540,6 +543,25 @@ internal class DalamudInterface : IInternalDisposableService
/// Widget to set current.
public void SetDataWindowWidget(IDataWindowWidget widget) => this.dataWindow.CurrentWidget = widget;
+ ///
+ /// Play an animation when a badge has been unlocked.
+ ///
+ /// The badge that has been unlocked.
+ public void StartBadgeUnlockAnimation(BadgeInfo badge)
+ {
+ var badgeTexture = Service.Get().GetDalamudTextureWrap(DalamudAsset.BadgeAtlas);
+ var uvs = badge.GetIconUv(badgeTexture.Width, badgeTexture.Height);
+
+ // TODO: Make it more fancy?
+ Service.Get().AddNotification(
+ new Notification
+ {
+ Title = "Badge unlocked!",
+ Content = $"You unlocked the badge '{badge.Name()}'",
+ Type = NotificationType.Success,
+ });
+ }
+
private void OnDraw()
{
this.FrameCount++;
@@ -561,6 +583,7 @@ internal class DalamudInterface : IInternalDisposableService
{
this.DrawHiddenDevMenuOpener();
this.DrawDevMenu();
+ this.DrawTitleScreenBadges();
if (Service.Get().GameUiHidden)
return;
@@ -591,6 +614,68 @@ internal class DalamudInterface : IInternalDisposableService
}
}
+ private void DrawTitleScreenBadges()
+ {
+ if (!this.titleScreenMenuWindow.IsOpen)
+ return;
+
+ var badgeManager = Service.Get();
+ if (!this.configuration.ShowBadgesOnTitleScreen || !badgeManager.UnlockedBadges.Any())
+ return;
+
+ var vp = ImGui.GetMainViewport();
+ ImGui.SetNextWindowPos(vp.Pos);
+ ImGui.SetNextWindowSize(vp.Size);
+ ImGuiHelpers.ForceNextWindowMainViewport();
+ ImGui.SetNextWindowBgAlpha(0f);
+
+ ImGui.Begin(
+ "###TitleScreenBadgeWindow"u8,
+ ImGuiWindowFlags.NoInputs | ImGuiWindowFlags.NoDocking | ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoMove |
+ ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoFocusOnAppearing | ImGuiWindowFlags.NoBringToFrontOnFocus |
+ ImGuiWindowFlags.NoNav);
+
+ var badgeAtlas = Service.Get().GetDalamudTextureWrap(DalamudAsset.BadgeAtlas);
+ var badgeSize = ImGuiHelpers.GlobalScale * 80;
+ var spacing = ImGuiHelpers.GlobalScale * 10;
+ const float margin = 60f;
+ var startPos = vp.Pos + new Vector2(vp.Size.X - margin, margin);
+
+ // Use the mouse position in screen space for hover detection because the usual ImGui hover checks
+ // don't work with this full-viewport overlay window setup.
+ var mouse = ImGui.GetMousePos();
+
+ foreach (var badge in badgeManager.UnlockedBadges)
+ {
+ var uvs = badge.GetIconUv(badgeAtlas.Width, badgeAtlas.Height);
+
+ startPos.X -= badgeSize;
+ ImGui.SetCursorPos(startPos);
+ ImGui.Image(badgeAtlas.Handle, new Vector2(badgeSize), uvs.Uv0, uvs.Uv1);
+
+ // Get the actual screen-space bounds of the image we just drew
+ var badgeMin = ImGui.GetItemRectMin();
+ var badgeMax = ImGui.GetItemRectMax();
+
+ // add spacing to the left for the next badge
+ startPos.X -= spacing;
+
+ // Manual hit test using mouse position
+ if (mouse.X >= badgeMin.X && mouse.X <= badgeMax.X && mouse.Y >= badgeMin.Y && mouse.Y <= badgeMax.Y)
+ {
+ ImGui.BeginTooltip();
+ ImGui.PushTextWrapPos(300 * ImGuiHelpers.GlobalScale);
+ ImGui.TextWrapped(badge.Name());
+ ImGui.Separator();
+ ImGui.TextColoredWrapped(ImGuiColors.DalamudGrey, badge.Description());
+ ImGui.PopTextWrapPos();
+ ImGui.EndTooltip();
+ }
+ }
+
+ ImGui.End();
+ }
+
private void DrawCreditsDarkeningAnimation()
{
using var style1 = ImRaii.PushStyle(ImGuiStyleVar.WindowRounding, 0f);
diff --git a/Dalamud/Interface/Internal/Windows/Settings/SettingsWindow.cs b/Dalamud/Interface/Internal/Windows/Settings/SettingsWindow.cs
index 581ef3746..62c931b20 100644
--- a/Dalamud/Interface/Internal/Windows/Settings/SettingsWindow.cs
+++ b/Dalamud/Interface/Internal/Windows/Settings/SettingsWindow.cs
@@ -46,6 +46,7 @@ internal sealed class SettingsWindow : Window
new SettingsTabLook(),
new SettingsTabAutoUpdates(),
new SettingsTabDtr(),
+ new SettingsTabBadge(),
new SettingsTabExperimental(),
new SettingsTabAbout()
];
diff --git a/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabBadge.cs b/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabBadge.cs
new file mode 100644
index 000000000..8e44ef7ea
--- /dev/null
+++ b/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabBadge.cs
@@ -0,0 +1,128 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+
+using CheapLoc;
+using Dalamud.Bindings.ImGui;
+using Dalamud.Interface.Colors;
+using Dalamud.Interface.Internal.Badge;
+using Dalamud.Interface.Internal.Windows.Settings.Widgets;
+using Dalamud.Interface.Utility;
+using Dalamud.Storage.Assets;
+using Dalamud.Utility.Internal;
+
+namespace Dalamud.Interface.Internal.Windows.Settings.Tabs;
+
+[SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1600:Elements should be documented", Justification = "Internals")]
+internal sealed class SettingsTabBadge : SettingsTab
+{
+ private string badgePassword = string.Empty;
+ private bool badgeWasError = false;
+
+ public override string Title => Loc.Localize("DalamudSettingsBadge", "Badges");
+
+ public override SettingsOpenKind Kind => SettingsOpenKind.ServerInfoBar;
+
+ public override SettingsEntry[] Entries { get; } =
+ [
+ new SettingsEntry(
+ LazyLoc.Localize("DalamudSettingsShowBadgesOnTitleScreen", "Show Badges on Title Screen"),
+ LazyLoc.Localize("DalamudSettingsShowBadgesOnTitleScreenHint", "If enabled, your unlocked badges will also be shown on the title screen."),
+ c => c.ShowBadgesOnTitleScreen,
+ (v, c) => c.ShowBadgesOnTitleScreen = v),
+ ];
+
+ public override void Draw()
+ {
+ var badgeManager = Service.Get();
+ var dalamudInterface = Service.Get();
+
+ ImGui.TextColoredWrapped(ImGuiColors.DalamudGrey, Loc.Localize("DalamudSettingServerInfoBarHint", "Plugins can put additional information into your server information bar(where world & time can be seen).\nYou can reorder and disable these here."));
+
+ ImGuiHelpers.ScaledDummy(5);
+
+ ImGui.Text(Loc.Localize("DalamudSettingsBadgesUnlock", "Unlock a badge"));
+ ImGui.TextColoredWrapped(ImGuiColors.DalamudGrey, Loc.Localize("DalamudSettingsBadgesUnlockHint", "If you have received a code for a badge, enter it here to unlock the badge.\nCodes are usually given out during community events or contests."));
+ ImGui.InputTextWithHint(
+ "##BadgePassword",
+ Loc.Localize("DalamudSettingsBadgesUnlockHintInput", "Enter badge code here"),
+ ref this.badgePassword,
+ 100);
+ ImGui.SameLine();
+ if (ImGui.Button(Loc.Localize("DalamudSettingsBadgesUnlockButton", "Unlock Badge")))
+ {
+ if (badgeManager.TryUnlockBadge(this.badgePassword.Trim(), BadgeUnlockMethod.User, out var unlockedBadge))
+ {
+ dalamudInterface.StartBadgeUnlockAnimation(unlockedBadge);
+ this.badgeWasError = false;
+ }
+ else
+ {
+ this.badgeWasError = true;
+ }
+ }
+
+ if (this.badgeWasError)
+ {
+ ImGuiHelpers.ScaledDummy(5);
+ ImGui.TextColored(ImGuiColors.DalamudRed, Loc.Localize("DalamudSettingsBadgesUnlockError", "Failed to unlock badge. The code may be invalid or you may have already unlocked this badge."));
+ }
+
+ ImGuiHelpers.ScaledDummy(5);
+
+ base.Draw();
+
+ ImGui.Separator();
+
+ ImGuiHelpers.ScaledDummy(5);
+
+ var haveBadges = badgeManager.UnlockedBadges.ToArray();
+
+ if (haveBadges.Length == 0)
+ {
+ ImGui.TextColoredWrapped(ImGuiColors.DalamudGrey, Loc.Localize("DalamudSettingServerInfoBarDidNone", "You did not unlock any badges yet.\nBadges can be unlocked by participating in community events or contests."));
+ }
+
+ var badgeTexture = Service.Get().GetDalamudTextureWrap(DalamudAsset.BadgeAtlas);
+ foreach (var badge in haveBadges)
+ {
+ var uvs = badge.GetIconUv(badgeTexture.Width, badgeTexture.Height);
+ var sectionSize = ImGuiHelpers.GlobalScale * 66;
+
+ var startCursor = ImGui.GetCursorPos();
+
+ ImGui.SetCursorPos(startCursor);
+
+ var iconSize = ImGuiHelpers.ScaledVector2(64, 64);
+ var cursorBeforeImage = ImGui.GetCursorPos();
+ var rectOffset = ImGui.GetWindowContentRegionMin() + ImGui.GetWindowPos();
+
+ if (ImGui.IsRectVisible(rectOffset + cursorBeforeImage, rectOffset + cursorBeforeImage + iconSize))
+ {
+ ImGui.Image(badgeTexture.Handle, iconSize, uvs.Uv0, uvs.Uv1);
+ ImGui.SameLine();
+ ImGui.SetCursorPos(cursorBeforeImage);
+ }
+
+ ImGui.SameLine();
+
+ ImGuiHelpers.ScaledDummy(5);
+ ImGui.SameLine();
+
+ var cursor = ImGui.GetCursorPos();
+
+ // Name
+ ImGui.Text(badge.Name());
+
+ cursor.Y += ImGui.GetTextLineHeightWithSpacing();
+ ImGui.SetCursorPos(cursor);
+
+ // Description
+ ImGui.TextWrapped(badge.Description());
+
+ startCursor.Y += sectionSize;
+ ImGui.SetCursorPos(startCursor);
+
+ ImGuiHelpers.ScaledDummy(5);
+ }
+ }
+}