diff --git a/docs/options-window.md b/docs/options-window.md
index 1648e12a34..50dd142c00 100644
--- a/docs/options-window.md
+++ b/docs/options-window.md
@@ -37,6 +37,9 @@ What was added on top of that:
centered dialogs remain capped at 2 times their reference size. Text grows
with the active layout up to the dock scale, shrinking only when needed to
fit an existing control.
+- **Classic bottom HUD** is an opt-in UI setting. It keeps the original 640-wide
+ bar uniformly scaled and centered on wide windows; leave it off to use the
+ responsive three-band layout. The change applies immediately.
## What it stores in `config.ini`
@@ -52,6 +55,8 @@ window writes to these sections:
`SavePassword`, `EncryptedUsername`, `EncryptedPassword`); see
"Remembering login credentials" below.
- `[ConnectionSettings]`
+- `[UI]` - locale, font, and `BottomHudClassic` (`0` by default; `1` enables the
+ classic centered bottom bar).
- `[Camera]` - orbital wheel-zoom radius (`Zoom`). **New in this PR**, the
only key the camera rework added.
diff --git a/src/Localization/Game.en.resx b/src/Localization/Game.en.resx
index cae014ca44..cc38b0944e 100644
--- a/src/Localization/Game.en.resx
+++ b/src/Localization/Game.en.resx
@@ -1433,6 +1433,9 @@
Windowed mode
+
+ Classic bottom HUD
+
Close
legacy_id=388,1002
diff --git a/src/source/App/Platform/Windows/Winmain.cpp b/src/source/App/Platform/Windows/Winmain.cpp
index 0b577b9b53..460067edea 100644
--- a/src/source/App/Platform/Windows/Winmain.cpp
+++ b/src/source/App/Platform/Windows/Winmain.cpp
@@ -327,6 +327,11 @@ void MuReapplyVSyncPreference()
g_hasPendingVSyncPreference = true;
}
+void MuApplyBottomHudLayoutPreference()
+{
+ UI::Scaling::SetBottomHudClassic(GameConfig::GetInstance().GetBottomHudClassic());
+}
+
BOOL GetFileNameOfFilePath(wchar_t* lpszFile, wchar_t* lpszPath)
{
auto iFind = (int)'\\';
@@ -1916,6 +1921,7 @@ int WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, PSTR szCmdLine, int nC
// Load game settings from INI file first
GameConfig::GetInstance().Load();
+ MuApplyBottomHudLayoutPreference();
// Check if animation task pool should be enabled (disabled by default)
{
diff --git a/src/source/Data/GameConfig/GameConfig.cpp b/src/source/Data/GameConfig/GameConfig.cpp
index dd505befc8..07ebcf6520 100644
--- a/src/source/Data/GameConfig/GameConfig.cpp
+++ b/src/source/Data/GameConfig/GameConfig.cpp
@@ -79,6 +79,7 @@ void GameConfig::Load()
m_uiLocale = ReadString(CfgSectionUI, CfgKeyUILocale, CfgDefaultUILocale);
m_fontSelection = ReadString(CfgSectionUI, CfgKeyFont, CfgDefaultFont);
+ m_bottomHudClassic = ReadBool(CfgSectionUI, CfgKeyBottomHudClassic, CfgDefaultBottomHudClassic);
m_zoom = ReadInt(CfgSectionCamera, CfgKeyZoom, CfgDefaultZoom);
m_sortParticleDraws = ReadBool(CfgSectionRender, CfgKeySortParticleDraws, CfgDefaultSortParticleDraws);
@@ -124,6 +125,7 @@ void GameConfig::Save()
WriteString(CfgSectionUI, CfgKeyUILocale, m_uiLocale);
WriteString(CfgSectionUI, CfgKeyFont, m_fontSelection);
+ WriteBool(CfgSectionUI, CfgKeyBottomHudClassic, m_bottomHudClassic);
WriteInt(CfgSectionCamera, CfgKeyZoom, m_zoom);
WriteBool(CfgSectionRender, CfgKeyVSync, m_vsyncEnabled);
@@ -226,6 +228,11 @@ void GameConfig::SetFontSelection(const std::wstring& font)
m_fontSelection = font;
}
+void GameConfig::SetBottomHudClassic(bool classic)
+{
+ m_bottomHudClassic = classic;
+}
+
void GameConfig::SetEncryptedUsername(const std::wstring& encryptedUsername)
{
m_encryptedUsername = encryptedUsername;
diff --git a/src/source/Data/GameConfig/GameConfig.h b/src/source/Data/GameConfig/GameConfig.h
index 2510f305e7..c9af27226d 100644
--- a/src/source/Data/GameConfig/GameConfig.h
+++ b/src/source/Data/GameConfig/GameConfig.h
@@ -67,6 +67,11 @@ class GameConfig
// UI font family name (GDI face name). Empty = platform default.
std::wstring GetFontSelection() const { return m_fontSelection; }
void SetFontSelection(const std::wstring& font);
+ bool GetBottomHudClassic() const
+ {
+ return m_bottomHudClassic;
+ }
+ void SetBottomHudClassic(bool classic);
// Chat commands - the favourites and the named templates of the command
// window. They belong to the installation, not to a character.
@@ -127,6 +132,7 @@ class GameConfig
std::wstring m_uiLocale;
std::wstring m_fontSelection;
+ bool m_bottomHudClassic;
int m_zoom;
bool m_sortParticleDraws;
diff --git a/src/source/Data/GameConfig/GameConfigConstants.h b/src/source/Data/GameConfig/GameConfigConstants.h
index 8f90c1333a..cc039c5a01 100644
--- a/src/source/Data/GameConfig/GameConfigConstants.h
+++ b/src/source/Data/GameConfig/GameConfigConstants.h
@@ -36,8 +36,8 @@ namespace CfgKeys
// UI
inline constexpr wchar_t CfgKeyUILocale[] = L"Locale";
- inline constexpr wchar_t CfgKeyFont[] = L"Font";
-
+ inline constexpr wchar_t CfgKeyFont[] = L"Font";
+ inline constexpr wchar_t CfgKeyBottomHudClassic[] = L"BottomHudClassic";
// Camera
inline constexpr wchar_t CfgKeyZoom[] = L"Zoom";
@@ -75,6 +75,8 @@ namespace CfgDefaults
// Windows, fontconfig "sans-serif" on Linux), so the look is unchanged until
// the user picks a font. Any value is passed through as the GDI face name.
inline constexpr wchar_t CfgDefaultFont[] = L"";
+ // Keep the responsive three-band layout unless the player opts in.
+ inline constexpr bool CfgDefaultBottomHudClassic = false;
// Legacy config compatibility only. SDL GPU ignores this key and default.
inline constexpr bool CfgDefaultCoreProfile = true;
diff --git a/src/source/UI/NewUI/Options/NewUIOptionWindow.cpp b/src/source/UI/NewUI/Options/NewUIOptionWindow.cpp
index 814daced38..cf56c7334c 100644
--- a/src/source/UI/NewUI/Options/NewUIOptionWindow.cpp
+++ b/src/source/UI/NewUI/Options/NewUIOptionWindow.cpp
@@ -9,6 +9,7 @@
#include "Audio/DSPlaySound.h"
#include "Data/GameConfig/GameConfig.h"
#include "Data/GameConfig/GameConfigConstants.h"
+#include "UI/Scaling/UITransform.h"
#include "Audio/AudioPlayer.h"
#include
#include
@@ -182,10 +183,16 @@ namespace
constexpr int FONT_LABEL_Y_LOCAL = 244;
constexpr int FONT_COMBO_X_LOCAL = 22;
constexpr int FONT_COMBO_Y_LOCAL = 257;
- constexpr int FONT_COMBO_WIDTH = 148;
- constexpr int FONT_COMBO_HEIGHT = 16;
+ constexpr int FONT_COMBO_WIDTH = 148;
+ constexpr int FONT_COMBO_HEIGHT = 16;
constexpr int FONT_COMBO_MAX_VISIBLE = 5;
-}
+
+ constexpr int CLASSIC_BOTTOM_HUD_CHECKBOX_Y_LOCAL = 395;
+ constexpr int CLASSIC_BOTTOM_HUD_LABEL_Y_LOCAL = 400;
+ constexpr int OPTION_CLOSE_Y_LOCAL = 427;
+ constexpr int OPTION_WINDOW_HEIGHT = 458;
+ constexpr int OPTION_FRAME_SLAT_COUNT = 35;
+} // namespace
//////////////////////////////////////////////////////////////////////
// Construction/Destruction
@@ -206,6 +213,7 @@ SEASON3B::CNewUIOptionWindow::CNewUIOptionWindow()
m_bRenderAllEffects = true;
m_iResolutionIndex = 0;
m_bWindowedMode = (g_bUseWindowMode == TRUE);
+ m_bBottomHudClassic = GameConfig::GetInstance().GetBottomHudClassic();
m_iLanguageIndex = FindCurrentLanguageIndex();
m_iFontIndex = FindCurrentFontIndex();
}
@@ -299,7 +307,7 @@ void SEASON3B::CNewUIOptionWindow::SetButtonInfo()
{
m_BtnClose.ChangeTextBackColor(RGBA(255, 255, 255, 0));
m_BtnClose.ChangeButtonImgState(true, IMAGE_OPTION_BTN_CLOSE, true);
- m_BtnClose.ChangeButtonInfo(m_Pos.x + 68, m_Pos.y + 388, 54, 30);
+ m_BtnClose.ChangeButtonInfo(m_Pos.x + 68, m_Pos.y + OPTION_CLOSE_Y_LOCAL, 54, 30);
m_BtnClose.ChangeImgColor(BUTTON_STATE_UP, RGBA(255, 255, 255, 255));
m_BtnClose.ChangeImgColor(BUTTON_STATE_DOWN, RGBA(255, 255, 255, 255));
}
@@ -392,12 +400,14 @@ bool SEASON3B::CNewUIOptionWindow::UpdateMouseEvent()
return false;
}
- bool oldWindowedMode = m_bWindowedMode;
+ const bool oldWindowedMode = m_bWindowedMode;
+ const bool oldBottomHudClassic = m_bBottomHudClassic;
HandleCheckboxInputs();
if (m_bWindowedMode != oldWindowedMode)
ApplyWindowModeToggle();
-
+ if (m_bBottomHudClassic != oldBottomHudClassic)
+ ApplyBottomHudClassic();
if (HandleVolumeSlider(m_iVolumeLevel, 104))
OnSoundVolumeChanged();
@@ -408,7 +418,7 @@ bool SEASON3B::CNewUIOptionWindow::UpdateMouseEvent()
// Combo box already processed at the top. Just consume clicks inside the
// option window itself so they don't fall through to the world.
- if (CheckMouseIn(m_Pos.x, m_Pos.y, 190, 419))
+ if (CheckMouseIn(m_Pos.x, m_Pos.y, 190, OPTION_WINDOW_HEIGHT))
return false;
return true;
@@ -418,11 +428,9 @@ void SEASON3B::CNewUIOptionWindow::HandleCheckboxInputs()
{
struct Checkbox { int yLocal; bool* target; };
const Checkbox boxes[] = {
- { 43, &m_bAutoAttack },
- { 65, &m_bWhisperSound },
- { 155, &m_bSlideHelp },
- { 238, &m_bRenderAllEffects },
- { 356, &m_bWindowedMode },
+ {43, &m_bAutoAttack}, {65, &m_bWhisperSound},
+ {155, &m_bSlideHelp}, {238, &m_bRenderAllEffects},
+ {356, &m_bWindowedMode}, {CLASSIC_BOTTOM_HUD_CHECKBOX_Y_LOCAL, &m_bBottomHudClassic},
};
constexpr int CHECKBOX_X_LOCAL = 150;
@@ -567,6 +575,7 @@ void SEASON3B::CNewUIOptionWindow::OpenningProcess()
m_FontCombo.SetSelectedIndex(m_iFontIndex);
m_FontCombo.Close();
m_bWindowedMode = (g_bUseWindowMode == TRUE);
+ m_bBottomHudClassic = GameConfig::GetInstance().GetBottomHudClassic();
}
void SEASON3B::CNewUIOptionWindow::ClosingProcess()
@@ -616,9 +625,8 @@ void SEASON3B::CNewUIOptionWindow::RenderFrame()
x = m_Pos.x;
y = m_Pos.y;
// Frame is composed of: 64px top + N*10px middle slats + 45px bottom. The
- // slat count is tuned so the frame reaches the Close button (Y 388) plus the
- // bottom border, after the Font/Language/Resolution/Windowed rows.
- constexpr int SLAT_COUNT = 30;
+ // slat count reaches the Close button after the UI settings rows.
+ constexpr int SLAT_COUNT = OPTION_FRAME_SLAT_COUNT;
constexpr float FRAME_HEIGHT = 64.f + SLAT_COUNT * 10.f + 45.f;
RenderImage(IMAGE_OPTION_FRAME_BACK, x, y, 190.f, FRAME_HEIGHT);
RenderImage(IMAGE_OPTION_FRAME_UP, x, y, 190.f, 64.f);
@@ -695,6 +703,10 @@ void SEASON3B::CNewUIOptionWindow::RenderContents()
y += 39.f;
RenderImage(IMAGE_OPTION_POINT, x, y, 10.f, 10.f); // Windowed Mode
g_pRenderText->RenderText(m_Pos.x + 40, m_Pos.y + 361, I18N::Game::WindowedMode);
+
+ y += 39.f;
+ RenderImage(IMAGE_OPTION_POINT, x, y, 10.f, 10.f); // Classic Bottom HUD
+ g_pRenderText->RenderText(m_Pos.x + 40, m_Pos.y + CLASSIC_BOTTOM_HUD_LABEL_Y_LOCAL, I18N::Game::ClassicBottomHUD);
}
void SEASON3B::CNewUIOptionWindow::RenderButtons()
@@ -772,6 +784,16 @@ void SEASON3B::CNewUIOptionWindow::RenderButtons()
RenderImage(IMAGE_OPTION_BTN_CHECK, m_Pos.x + 150, m_Pos.y + 356, 15, 15, 0, 15.f);
}
+ if (m_bBottomHudClassic)
+ {
+ RenderImage(IMAGE_OPTION_BTN_CHECK, m_Pos.x + 150, m_Pos.y + CLASSIC_BOTTOM_HUD_CHECKBOX_Y_LOCAL, 15, 15, 0, 0);
+ }
+ else
+ {
+ RenderImage(IMAGE_OPTION_BTN_CHECK, m_Pos.x + 150, m_Pos.y + CLASSIC_BOTTOM_HUD_CHECKBOX_Y_LOCAL, 15, 15, 0,
+ 15.f);
+ }
+
// Combo boxes drawn last so their expanded dropdowns sit on top of
// anything else in the window. Within the combo pair, render the
// closed one(s) first and any open dropdown last - otherwise a combo
@@ -966,3 +988,10 @@ void SEASON3B::CNewUIOptionWindow::ApplyWindowModeToggle()
// toggle again next frame; the user must release and click again.
g_pNewKeyInput->SetKeyState(VK_LBUTTON, SEASON3B::CNewKeyInput::KEY_NONE);
}
+
+void SEASON3B::CNewUIOptionWindow::ApplyBottomHudClassic()
+{
+ UI::Scaling::SetBottomHudClassic(m_bBottomHudClassic);
+ GameConfig::GetInstance().SetBottomHudClassic(m_bBottomHudClassic);
+ GameConfig::GetInstance().Save();
+}
diff --git a/src/source/UI/NewUI/Options/NewUIOptionWindow.h b/src/source/UI/NewUI/Options/NewUIOptionWindow.h
index ebc807f735..a757fe327a 100644
--- a/src/source/UI/NewUI/Options/NewUIOptionWindow.h
+++ b/src/source/UI/NewUI/Options/NewUIOptionWindow.h
@@ -114,6 +114,7 @@ namespace SEASON3B
bool m_bRenderAllEffects;
int m_iResolutionIndex;
bool m_bWindowedMode;
+ bool m_bBottomHudClassic;
int m_iLanguageIndex;
int m_iFontIndex;
@@ -134,6 +135,7 @@ namespace SEASON3B
void InitResolutionCombo();
void SyncResolutionComboToWindow();
void ApplyWindowModeToggle();
+ void ApplyBottomHudClassic();
void ApplyLanguage();
int FindCurrentLanguageIndex();
diff --git a/src/source/UI/Scaling/UITransform.cpp b/src/source/UI/Scaling/UITransform.cpp
index 3384ab4238..3cb819f9a5 100644
--- a/src/source/UI/Scaling/UITransform.cpp
+++ b/src/source/UI/Scaling/UITransform.cpp
@@ -55,6 +55,13 @@ UI::Scaling::Transform BottomHudTransform(int windowWidth, int windowHeight, flo
return {scale, scale, offsetX, static_cast(windowHeight) - kReferenceHeight * scale, scale};
}
+UI::Scaling::Transform BottomHudClassicTransform(int windowWidth, int windowHeight)
+{
+ const float scale = UI::Scaling::BottomHudScale(windowWidth, windowHeight);
+ const float offsetX = (static_cast(windowWidth) - kReferenceWidth * scale) * 0.5f;
+ return BottomHudTransform(windowWidth, windowHeight, offsetX);
+}
+
bool ContainsLogicalRect(const UI::Scaling::Transform& transform, float windowX, float windowY, float left,
float top, float right, float bottom)
{
@@ -82,11 +89,14 @@ float CappedUniformScale(int windowWidth, int windowHeight, float maximumScale)
UI::Scaling::Transform DockTransform(int windowWidth, int windowHeight)
{
const float scale = CappedUniformScale(windowWidth, windowHeight, kMaximumDockScale);
- const float offsetY = static_cast(RoundedBottomHudTop(windowWidth, windowHeight))
- - UI::Scaling::DockLogicalBottom * scale;
+ const float offsetY =
+ static_cast(RoundedBottomHudTop(windowWidth, windowHeight)) - UI::Scaling::DockLogicalBottom * scale;
return {scale, scale, 0.0f, offsetY, scale};
}
-}
+
+bool g_bottomHudClassic = false;
+
+} // namespace
UI::Scaling::Transform UI::Scaling::ScreenOverlayTransform(int windowWidth, int windowHeight)
{
@@ -133,13 +143,27 @@ float UI::Scaling::BottomHudScale(int windowWidth, int windowHeight)
kMaximumHudScale * contentScale);
}
+void UI::Scaling::SetBottomHudClassic(bool classic)
+{
+ g_bottomHudClassic = classic;
+}
+
+bool UI::Scaling::IsBottomHudClassic()
+{
+ return g_bottomHudClassic;
+}
+
UI::Scaling::Transform UI::Scaling::BottomHudLeftTransform(int windowWidth, int windowHeight)
{
+ if (g_bottomHudClassic)
+ return BottomHudClassicTransform(windowWidth, windowHeight);
return BottomHudTransform(windowWidth, windowHeight, 0.0f);
}
UI::Scaling::Transform UI::Scaling::BottomHudCenterTransform(int windowWidth, int windowHeight)
{
+ if (g_bottomHudClassic)
+ return BottomHudClassicTransform(windowWidth, windowHeight);
const float scale = BottomHudScale(windowWidth, windowHeight);
return BottomHudTransform(windowWidth, windowHeight,
static_cast(windowWidth) * 0.5f - 320.0f * scale);
@@ -147,6 +171,8 @@ UI::Scaling::Transform UI::Scaling::BottomHudCenterTransform(int windowWidth, in
UI::Scaling::Transform UI::Scaling::BottomHudRightTransform(int windowWidth, int windowHeight)
{
+ if (g_bottomHudClassic)
+ return BottomHudClassicTransform(windowWidth, windowHeight);
const float scale = BottomHudScale(windowWidth, windowHeight);
return BottomHudTransform(windowWidth, windowHeight,
static_cast(windowWidth) - kReferenceWidth * scale);
@@ -154,6 +180,8 @@ UI::Scaling::Transform UI::Scaling::BottomHudRightTransform(int windowWidth, int
UI::Scaling::Transform UI::Scaling::BottomHudExperienceTransform(int windowWidth, int windowHeight)
{
+ if (g_bottomHudClassic)
+ return BottomHudClassicTransform(windowWidth, windowHeight);
Transform transform = BottomHudLeftTransform(windowWidth, windowHeight);
transform.scaleX = static_cast(windowWidth) / kReferenceWidth;
return transform;
diff --git a/src/source/UI/Scaling/UITransform.h b/src/source/UI/Scaling/UITransform.h
index fe095d307b..62c635e1e6 100644
--- a/src/source/UI/Scaling/UITransform.h
+++ b/src/source/UI/Scaling/UITransform.h
@@ -69,6 +69,8 @@ namespace UI::Scaling
Transform LegacyUiTransform(int windowWidth, int windowHeight);
Transform PanelTransform(int windowWidth, int windowHeight);
float BottomHudScale(int windowWidth, int windowHeight);
+ void SetBottomHudClassic(bool classic);
+ bool IsBottomHudClassic();
Transform BottomHudLeftTransform(int windowWidth, int windowHeight);
Transform BottomHudCenterTransform(int windowWidth, int windowHeight);
Transform BottomHudRightTransform(int windowWidth, int windowHeight);
diff --git a/tests/ui/test_ui_scaling.cpp b/tests/ui/test_ui_scaling.cpp
index 0af1ff621a..cd79a2fdf6 100644
--- a/tests/ui/test_ui_scaling.cpp
+++ b/tests/ui/test_ui_scaling.cpp
@@ -615,6 +615,30 @@ TEST_CASE("bottom HUD regional transforms round trip window positions [ui][scali
CHECK(UI::Scaling::LogicalY(center, UI::Scaling::PositionY(center, 450.0f)) == doctest::Approx(450.0f));
}
+TEST_CASE("classic bottom HUD centers the whole bar and keeps the rail its width [ui][scaling]")
+{
+ CHECK_FALSE(CfgDefaults::CfgDefaultBottomHudClassic);
+ CHECK_FALSE(UI::Scaling::IsBottomHudClassic());
+ UI::Scaling::SetBottomHudClassic(true);
+ const auto left = UI::Scaling::BottomHudLeftTransform(1920, 1080);
+ const auto center = UI::Scaling::BottomHudCenterTransform(1920, 1080);
+ const auto right = UI::Scaling::BottomHudRightTransform(1920, 1080);
+ const auto experience = UI::Scaling::BottomHudExperienceTransform(1920, 1080);
+ UI::Scaling::SetBottomHudClassic(false);
+ CHECK_FALSE(UI::Scaling::IsBottomHudClassic());
+
+ CHECK(left.scaleX == doctest::Approx(2.0f));
+ CHECK(left.offsetX == doctest::Approx(320.0f));
+ CHECK(center.offsetX == doctest::Approx(left.offsetX));
+ CHECK(right.offsetX == doctest::Approx(left.offsetX));
+ CHECK(UI::Scaling::PositionX(left, 0.0f) == doctest::Approx(320.0f));
+ CHECK(UI::Scaling::PositionX(right, 640.0f) == doctest::Approx(1600.0f));
+ CHECK(UI::Scaling::PositionX(center, 320.0f) == doctest::Approx(960.0f));
+ CHECK(experience.scaleX == doctest::Approx(2.0f));
+ CHECK(UI::Scaling::PositionX(experience, 640.0f) == doctest::Approx(1600.0f));
+ CHECK(UI::Scaling::PositionY(experience, 480.0f) == doctest::Approx(1080.0f));
+}
+
TEST_CASE("experience transform spans the window with HUD vertical scale [ui][scaling]")
{
const auto experience = UI::Scaling::BottomHudExperienceTransform(1920, 1200);