瀏覽代碼

Add persistent user settings support. (#374)

* Add persistent user settings support.

Signed-off-by: Mike Balfour <[email protected]>

* PR feedback.

Signed-off-by: Mike Balfour <[email protected]>

---------

Signed-off-by: Mike Balfour <[email protected]>
Mike Balfour 2 年之前
父節點
當前提交
3b47d1c5e9

+ 104 - 88
Gem/Code/Source/Components/UI/UiSettingsComponent.cpp

@@ -6,30 +6,16 @@
  */
  */
 
 
 #include <Atom/RHI/Factory.h>
 #include <Atom/RHI/Factory.h>
-#include <AzCore/Console/IConsole.h>
 #include <AzCore/Interface/Interface.h>
 #include <AzCore/Interface/Interface.h>
 #include <AzCore/Serialization/EditContext.h>
 #include <AzCore/Serialization/EditContext.h>
-#include <IAudioSystem.h>
 #include <LyShine/Bus/UiButtonBus.h>
 #include <LyShine/Bus/UiButtonBus.h>
 #include <LyShine/Bus/UiElementBus.h>
 #include <LyShine/Bus/UiElementBus.h>
 #include <LyShine/Bus/UiTextBus.h>
 #include <LyShine/Bus/UiTextBus.h>
 #include <Source/Components/UI/UiSettingsComponent.h>
 #include <Source/Components/UI/UiSettingsComponent.h>
+#include <Source/UserSettings/MultiplayerSampleUserSettings.h>
 
 
 namespace MultiplayerSample
 namespace MultiplayerSample
 {
 {
-    void MpsSettings::Reflect(AZ::ReflectContext* context)
-    {
-        if (AZ::SerializeContext* serializeContext = azrtti_cast<AZ::SerializeContext*>(context))
-        {
-            serializeContext->Class<MpsSettings>()
-                ->Version(0)
-                ->Field("GraphicsApi", &MpsSettings::m_atomApiType)
-                ->Field("MasterVolume", &MpsSettings::m_masterVolume)
-                ->Field("TextureQuality", &MpsSettings::m_streamingImageMipBias)
-                ;
-        }
-    }
-
     void UiToggle::Reflect(AZ::ReflectContext* context)
     void UiToggle::Reflect(AZ::ReflectContext* context)
     {
     {
         if (AZ::SerializeContext* serializeContext = azrtti_cast<AZ::SerializeContext*>(context))
         if (AZ::SerializeContext* serializeContext = azrtti_cast<AZ::SerializeContext*>(context))
@@ -41,7 +27,6 @@ namespace MultiplayerSample
                 ->Field("RightButton", &UiToggle::m_rightButtonEntity)
                 ->Field("RightButton", &UiToggle::m_rightButtonEntity)
                 ;
                 ;
 
 
-
             if (AZ::EditContext* editContext = serializeContext->GetEditContext())
             if (AZ::EditContext* editContext = serializeContext->GetEditContext())
             {
             {
                 editContext->Class<UiToggle>("Ui Toggle", "Manages the user settings")
                 editContext->Class<UiToggle>("Ui Toggle", "Manages the user settings")
@@ -56,7 +41,6 @@ namespace MultiplayerSample
 
 
     void UiSettingsComponent::Reflect(AZ::ReflectContext* context)
     void UiSettingsComponent::Reflect(AZ::ReflectContext* context)
     {
     {
-        MpsSettings::Reflect(context);
         UiToggle::Reflect(context);
         UiToggle::Reflect(context);
 
 
         if (AZ::SerializeContext* serializeContext = azrtti_cast<AZ::SerializeContext*>(context))
         if (AZ::SerializeContext* serializeContext = azrtti_cast<AZ::SerializeContext*>(context))
@@ -83,20 +67,11 @@ namespace MultiplayerSample
 
 
     void UiSettingsComponent::Activate()
     void UiSettingsComponent::Activate()
     {
     {
-        // Initialize our user settings 
-
-        // Initialize the current streaming image mip bias setting.
-        if (AZ::IConsole* console = AZ::Interface<AZ::IConsole>::Get(); console)
-        {
-            int16_t mipBias = 0;
-            console->GetCvarValue("r_streamingImageMipBias", mipBias);
-            m_settings.m_streamingImageMipBias = aznumeric_cast<uint8_t>(mipBias);
-        }
-
-        // Initialize the graphics API type
-        m_settings.m_atomApiType = AZ::RHI::Factory::Get().GetAPIUniqueIndex();
-
-        // There's currently no way to initialize the master volume, this doesn't seem to be fetchable anywhere.
+        // Loads and applies the current user settings when this component activates.
+        // The user settings should *already* be loaded and applied at Launcher startup, but connecting to the server
+        // and switching levels can cause some engine settings to reset themselves, so this will reapply the desired
+        // user settings again.
+        MultiplayerSampleUserSettingsRequestBus::Broadcast(&MultiplayerSampleUserSettingsRequestBus::Events::Load);
 
 
         // Initialize the toggles to the current values
         // Initialize the toggles to the current values
         OnGraphicsApiToggle(ToggleDirection::None);
         OnGraphicsApiToggle(ToggleDirection::None);
@@ -140,91 +115,132 @@ namespace MultiplayerSample
     {
     {
     }
     }
 
 
+    template<typename ValueType>
+    uint32_t UiSettingsComponent::GetRotatedIndex(
+        const AZStd::vector<AZStd::pair<ValueType, AZStd::string>>& valuesToLabels,
+        const ValueType& value, ToggleDirection toggleDirection)
+    {
+        const size_t totalValues = valuesToLabels.size();
+
+        uint32_t curIndex = 0;
+
+        // Loop through and look for the correct value
+        for (size_t index = 0; index < totalValues; index++)
+        {
+            if (value == valuesToLabels[index].first)
+            {
+                curIndex = aznumeric_cast<uint32_t>(index);
+                break;
+            }
+        }
+
+        switch (toggleDirection)
+        {
+        case ToggleDirection::Left:
+            return aznumeric_cast<uint32_t>((curIndex + (totalValues - 1)) % totalValues);
+        case ToggleDirection::Right:
+            return aznumeric_cast<uint32_t>((curIndex + 1) % totalValues);
+        default:
+            return curIndex;
+        }
+    }
+
     void UiSettingsComponent::OnGraphicsApiToggle(ToggleDirection toggleDirection)
     void UiSettingsComponent::OnGraphicsApiToggle(ToggleDirection toggleDirection)
     {
     {
         // This list is expected to match the values in AZ::RHI::ApiIndex.
         // This list is expected to match the values in AZ::RHI::ApiIndex.
-        const char* labels[] =
+        const AZStd::vector<AZStd::pair<AZStd::string, AZStd::string>> valuesToLabels =
         {
         {
-            "Null",
-            "DirectX 12",
-            "Vulkan",
-            "Metal"
+            { "null", "Null" },
+            { "dx12", "DirectX 12" },
+            { "vulkan", "Vulkan" },
+            { "metal", "Metal" }
         };
         };
 
 
-        const size_t NumLabels = AZ_ARRAY_SIZE(labels);
+        // Get the current api selection.
+        AZStd::string graphicsApi;
+        MultiplayerSampleUserSettingsRequestBus::BroadcastResult(
+            graphicsApi, &MultiplayerSampleUserSettingsRequestBus::Events::GetGraphicsApi);
 
 
-        if (toggleDirection != ToggleDirection::None)
+        // If there isn't anything stored in the user settings yet, default to the currently-loaded api.
+        if (graphicsApi.empty())
         {
         {
-            m_settings.m_atomApiType = (toggleDirection == ToggleDirection::Right)
-                ? (m_settings.m_atomApiType + 1) % NumLabels
-                : (m_settings.m_atomApiType + (NumLabels - 1)) % NumLabels
-                ;
+            graphicsApi = AZ::RHI::Factory::Get().GetName().GetStringView();
         }
         }
 
 
-        UiTextBus::Event(m_graphicsApiToggle.m_labelEntity, &UiTextInterface::SetText, labels[m_settings.m_atomApiType]);
+        // Rotate the index based on toggle direction.
+        uint32_t graphicsApiIndex = GetRotatedIndex(valuesToLabels, graphicsApi, toggleDirection);
+
+        UiTextBus::Event(m_graphicsApiToggle.m_labelEntity, &UiTextInterface::SetText, valuesToLabels[graphicsApiIndex].second);
+
+        MultiplayerSampleUserSettingsRequestBus::Broadcast(
+            &MultiplayerSampleUserSettingsRequestBus::Events::SetGraphicsApi, valuesToLabels[graphicsApiIndex].first);
+
+        MultiplayerSampleUserSettingsRequestBus::Broadcast(&MultiplayerSampleUserSettingsRequestBus::Events::Save);
     }
     }
 
 
     void UiSettingsComponent::OnTextureQualityToggle(ToggleDirection toggleDirection)
     void UiSettingsComponent::OnTextureQualityToggle(ToggleDirection toggleDirection)
     {
     {
-        const char* labels[] =
+        const AZStd::vector<AZStd::pair<int16_t, AZStd::string>> valuesToLabels =
         {
         {
-            "Ultra (4K)",
-            "High (2K)",
-            "Medium (1K)",
-            "Low (512)",
-            "Very Low (256)",
-            "Extremely Low (128)",
-            "Rock Bottom (64)"
+            { aznumeric_cast<int16_t>(6), "Rock Bottom (64)" },
+            { aznumeric_cast<int16_t>(5), "Extremely Low (128)" },
+            { aznumeric_cast<int16_t>(4), "Very Low (256)" },
+            { aznumeric_cast<int16_t>(3), "Low (512)" },
+            { aznumeric_cast<int16_t>(2), "Medium (1K)" },
+            { aznumeric_cast<int16_t>(1), "High (2K)" },
+            { aznumeric_cast<int16_t>(0), "Ultra (4K)" },
         };
         };
 
 
-        const size_t NumLabels = AZ_ARRAY_SIZE(labels);
+        // Get the current texture quality value.
+        int16_t textureQuality = 0;
+        MultiplayerSampleUserSettingsRequestBus::BroadcastResult(
+            textureQuality, &MultiplayerSampleUserSettingsRequestBus::Events::GetTextureQuality);
 
 
-        if (toggleDirection != ToggleDirection::None)
-        {
-            // As we go from left to right on our settings, we want our textureQuality number to go from 6 down to 0
-            // because smaller mip bias numbers mean higher-resolution textures.
-            m_settings.m_streamingImageMipBias = (toggleDirection == ToggleDirection::Right)
-                ? (m_settings.m_streamingImageMipBias + (NumLabels - 1)) % NumLabels
-                : (m_settings.m_streamingImageMipBias + 1) % NumLabels
-                ;
-        }
+        // Rotate the index based on toggle direction.
+        uint32_t textureQualityIndex = GetRotatedIndex(valuesToLabels, textureQuality, toggleDirection);
 
 
-        AZ::IConsole* console = AZ::Interface<AZ::IConsole>::Get();
-        if (console)
-        {
-            AZ::CVarFixedString commandString = AZ::CVarFixedString::format("r_streamingImageMipBias %" PRId16, m_settings.m_streamingImageMipBias);
-            console->PerformCommand(commandString.c_str());
-        }
+        UiTextBus::Event(m_textureQualityToggle.m_labelEntity, &UiTextInterface::SetText, valuesToLabels[textureQualityIndex].second);
 
 
-        UiTextBus::Event(m_textureQualityToggle.m_labelEntity, &UiTextInterface::SetText, labels[m_settings.m_streamingImageMipBias]);
+        MultiplayerSampleUserSettingsRequestBus::Broadcast(
+            &MultiplayerSampleUserSettingsRequestBus::Events::SetTextureQuality, valuesToLabels[textureQualityIndex].first);
+
+        MultiplayerSampleUserSettingsRequestBus::Broadcast(&MultiplayerSampleUserSettingsRequestBus::Events::Save);
     }
     }
 
 
     void UiSettingsComponent::OnMasterVolumeToggle(ToggleDirection toggleDirection)
     void UiSettingsComponent::OnMasterVolumeToggle(ToggleDirection toggleDirection)
     {
     {
-        if (toggleDirection != ToggleDirection::None)
+        const AZStd::vector<AZStd::pair<uint8_t, AZStd::string>> valuesToLabels =
         {
         {
-            m_settings.m_masterVolume = (toggleDirection == ToggleDirection::Right)
-                ? (m_settings.m_masterVolume + 10) % 110
-                : (m_settings.m_masterVolume + 100) % 110
-                ;
-        }
+            { aznumeric_cast<uint8_t>(0), "0 (off)" },
+            { aznumeric_cast<uint8_t>(10), "10" },
+            { aznumeric_cast<uint8_t>(20), "20" },
+            { aznumeric_cast<uint8_t>(30), "30" },
+            { aznumeric_cast<uint8_t>(40), "40" },
+            { aznumeric_cast<uint8_t>(50), "50" },
+            { aznumeric_cast<uint8_t>(60), "60" },
+            { aznumeric_cast<uint8_t>(70), "70" },
+            { aznumeric_cast<uint8_t>(80), "80" },
+            { aznumeric_cast<uint8_t>(90), "90" },
+            { aznumeric_cast<uint8_t>(100), "100 (max)" },
+        };
 
 
-        auto audioSystem = AZ::Interface<Audio::IAudioSystem>::Get();
-        if (audioSystem)
-        {
-            Audio::TAudioObjectID rtpcId = audioSystem->GetAudioRtpcID("Volume_Master");
+        // Get the current master volume value.
+        uint8_t masterVolume = 0;
+        MultiplayerSampleUserSettingsRequestBus::BroadcastResult(
+            masterVolume, &MultiplayerSampleUserSettingsRequestBus::Events::GetMasterVolume);
 
 
-            if (rtpcId != INVALID_AUDIO_CONTROL_ID)
-            {
-                Audio::ObjectRequest::SetParameterValue setParameter;
-                setParameter.m_audioObjectId = INVALID_AUDIO_OBJECT_ID;
-                setParameter.m_parameterId = rtpcId;
-                setParameter.m_value = m_settings.m_masterVolume / 100.0f;
-                AZ::Interface<Audio::IAudioSystem>::Get()->PushRequest(AZStd::move(setParameter));
-            }
-        }
+        // Make sure our master volume is a multiple of 10.
+        masterVolume = (masterVolume / 10) * 10;
+
+        // Rotate the index based on toggle direction.
+        uint32_t masterVolumeIndex = GetRotatedIndex(valuesToLabels, masterVolume, toggleDirection);
+
+        UiTextBus::Event(m_masterVolumeToggle.m_labelEntity, &UiTextInterface::SetText, valuesToLabels[masterVolumeIndex].second);
+
+        MultiplayerSampleUserSettingsRequestBus::Broadcast(
+            &MultiplayerSampleUserSettingsRequestBus::Events::SetMasterVolume, valuesToLabels[masterVolumeIndex].first);
 
 
-        UiTextBus::Event(m_masterVolumeToggle.m_labelEntity, &UiTextInterface::SetText, AZStd::string::format("%d", m_settings.m_masterVolume));
+        MultiplayerSampleUserSettingsRequestBus::Broadcast(&MultiplayerSampleUserSettingsRequestBus::Events::Save);
     }
     }
 
 
 }
 }

+ 5 - 19
Gem/Code/Source/Components/UI/UiSettingsComponent.h

@@ -12,23 +12,6 @@
 
 
 namespace MultiplayerSample
 namespace MultiplayerSample
 {
 {
-    //! These are all of the user settings that MPS supports.
-    struct MpsSettings
-    {
-        AZ_TYPE_INFO(MpsSettings, "{1E545ABF-6650-41D8-AC69-9C50BB5561F0}");
-        static void Reflect(AZ::ReflectContext* context);
-
-        //! The API type that Atom should use at startup. (This value comes from AZ::RHI::APIIndex)
-        uint32_t m_atomApiType = 0;
-
-        //! The master audio volume (0 - 100). 0 is silent, 100 is max volume.
-        uint8_t m_masterVolume = 100;
-
-        //! The streaming image texture mip bias (0 - N). This affects the max mipmap level that will be loaded for streaming images.
-        //! This doesn't affect other types of images like UI or VFX.
-        uint8_t m_streamingImageMipBias = 1;
-    };
-
     struct UiToggle
     struct UiToggle
     {
     {
         AZ_TYPE_INFO(UiToggle, "{60AD7DDE-1730-41D8-BB82-630FF8008370}");
         AZ_TYPE_INFO(UiToggle, "{60AD7DDE-1730-41D8-BB82-630FF8008370}");
@@ -61,10 +44,13 @@ namespace MultiplayerSample
         void OnTextureQualityToggle(ToggleDirection toggleDirection);
         void OnTextureQualityToggle(ToggleDirection toggleDirection);
         void OnMasterVolumeToggle(ToggleDirection toggleDirection);
         void OnMasterVolumeToggle(ToggleDirection toggleDirection);
 
 
+        template<typename ValueType>
+        static uint32_t GetRotatedIndex(
+            const AZStd::vector<AZStd::pair<ValueType, AZStd::string>>& valuesToLabels,
+            const ValueType& value, ToggleDirection toggleDirection);
+
         UiToggle m_graphicsApiToggle;
         UiToggle m_graphicsApiToggle;
         UiToggle m_textureQualityToggle;
         UiToggle m_textureQualityToggle;
         UiToggle m_masterVolumeToggle;
         UiToggle m_masterVolumeToggle;
-
-        MpsSettings m_settings;
     };
     };
 }
 }

+ 9 - 1
Gem/Code/Source/MultiplayerSampleModule.cpp

@@ -18,7 +18,7 @@
 #include <Components/BackgroundMusicComponent.h>
 #include <Components/BackgroundMusicComponent.h>
 #include <Components/ScriptableDecalComponent.h>
 #include <Components/ScriptableDecalComponent.h>
 #include <Source/AutoGen/AutoComponentTypes.h>
 #include <Source/AutoGen/AutoComponentTypes.h>
-#include "MultiplayerSampleSystemComponent.h"
+#include <MultiplayerSampleSystemComponent.h>
 
 
 #if AZ_TRAIT_CLIENT
 #if AZ_TRAIT_CLIENT
 #   include <Components/UI/HUDComponent.h>
 #   include <Components/UI/HUDComponent.h>
@@ -26,6 +26,7 @@
 #   include <Components/UI/UiRestBetweenRoundsComponent.h>
 #   include <Components/UI/UiRestBetweenRoundsComponent.h>
 #   include <Components/UI/UiSettingsComponent.h>
 #   include <Components/UI/UiSettingsComponent.h>
 #   include <Components/UI/UiStartMenuComponent.h>
 #   include <Components/UI/UiStartMenuComponent.h>
+    #include <UserSettings/MultiplayerSampleUserSettings.h>
 #endif
 #endif
 
 
 namespace MultiplayerSample
 namespace MultiplayerSample
@@ -72,6 +73,13 @@ namespace MultiplayerSample
                 azrtti_typeid<MultiplayerSampleSystemComponent>(),
                 azrtti_typeid<MultiplayerSampleSystemComponent>(),
             };
             };
         }
         }
+
+#if AZ_TRAIT_CLIENT
+        // This needs to be created as a part of the MultiplayerSampleModule, not during any sort of System Component activation.
+        // It will affect registry keys that get read by System Components as a part of their activation and we can't guarantee
+        // that those other core System Components will get started after our game-specific one.
+        MultiplayerSampleUserSettings m_userSettings;
+#endif
     };
     };
 }
 }
 
 

+ 212 - 0
Gem/Code/Source/UserSettings/MultiplayerSampleUserSettings.cpp

@@ -0,0 +1,212 @@
+/*
+ * Copyright (c) Contributors to the Open 3D Engine Project.
+ * For complete copyright and license terms please see the LICENSE at the root of this distribution.
+ *
+ * SPDX-License-Identifier: Apache-2.0 OR MIT
+ *
+ */
+
+#include <Atom/RPI.Public/Image/ImageSystem.h>
+#include <Atom/RPI.Public/Image/StreamingImage.h>
+#include <Atom/RPI.Public/Image/StreamingImagePool.h>
+
+#include <AzCore/IO/GenericStreams.h>
+#include <AzCore/Settings/SettingsRegistry.h>
+#include <AzCore/Settings/SettingsRegistryMergeUtils.h>
+#include <AzCore/Utils/Utils.h>
+#include <AzFramework/FileFunc/FileFunc.h>
+#include <IAudioSystem.h>
+#include <UserSettings/MultiplayerSampleUserSettings.h>
+
+namespace MultiplayerSample
+{
+    MultiplayerSampleUserSettings::MultiplayerSampleUserSettings()
+        : m_graphicsApiKey(BaseRegistryKey + FixedString("/ApiName"))
+        , m_masterVolumeKey(BaseRegistryKey + FixedString("/MasterVolume"))
+        , m_textureQualityKey(BaseRegistryKey + FixedString("/TextureQuality"))
+
+    {
+        MultiplayerSampleUserSettingsRequestBus::Handler::BusConnect();
+
+        // Create a full path including filename for the user settings file.
+        m_userSettingsPath = AZ::Utils::GetProjectUserPath();
+        m_userSettingsPath /= "Registry";
+        m_userSettingsPath /= "MultiplayerSampleUserSettings.setreg";
+
+        // Load all of our settings keys, create default values if they don't exist and initialize the engine settings as appropriate.
+        Load();
+    }
+
+    MultiplayerSampleUserSettings::~MultiplayerSampleUserSettings()
+    {
+        MultiplayerSampleUserSettingsRequestBus::Handler::BusDisconnect();
+
+        // Always auto-save the user settings on destruction.
+        Save();
+    }
+
+    void MultiplayerSampleUserSettings::Load()
+    {
+        if (auto* registry = AZ::SettingsRegistry::Get(); registry != nullptr)
+        {
+            // Read the setreg file from a loose file into a string in memory. This isn't technically a "cfg" file,
+            // but the method does the exact set of steps needed here to read a loose file into memory, so even though
+            // it has a slightly misleading name, it keeps us from duplicating the code.
+            AZ::Outcome<AZStd::string, AZStd::string> userSettings = 
+                AzFramework::FileFunc::GetCfgFileContents(AZStd::string(m_userSettingsPath.FixedMaxPathString()));
+
+            if (userSettings.IsSuccess())
+            {
+                // Merge the user settings file under the base "/O3DE/MultiplayerSample/User/Settings" key.
+                // This will ensure that it cannot overwrite any other engine settings.
+                // MergeSettings() is used here instead of MergeSettingsFile() because MergeSettingsFile() uses
+                // FileIOBase to read in the file, which will attempt to read it from a PAK file in PAK file builds.
+                // Our settings file will always be a loose file, so we instead read it into a buffer beforehand and then
+                // apply it here from the in-memory buffer.
+                [[maybe_unused]] auto mergeSuccess = registry->MergeSettings(userSettings.GetValue(),
+                    AZ::SettingsRegistryInterface::Format::JsonMergePatch, BaseRegistryKey);
+
+                AZ_Error("UserSettings", mergeSuccess, "Failed to merge user settings into the O3DE registry.");
+            }
+
+            // Get the current settings values or the defaults if the keys don't exist.
+            AZStd::string apiName = GetGraphicsApi();
+            uint8_t masterVolume = GetMasterVolume();
+            int16_t textureQuality = GetTextureQuality();
+
+            // Set the settings values, which will notify the engine as well as write the keys back into the registry.
+            SetGraphicsApi(apiName);
+            SetMasterVolume(masterVolume);
+            SetTextureQuality(textureQuality);
+        }
+    }
+
+    AZStd::string MultiplayerSampleUserSettings::GetGraphicsApi()
+    {
+        // Default to an empty string, which will just use the default API.
+        AZStd::string apiName;
+
+        if (auto* registry = AZ::SettingsRegistry::Get(); registry != nullptr)
+        {
+            registry->Get(apiName, m_graphicsApiKey.c_str());
+        }
+
+        return apiName;
+    }
+
+    void MultiplayerSampleUserSettings::SetGraphicsApi(const AZStd::string& apiName)
+    {
+        if (auto* registry = AZ::SettingsRegistry::Get(); registry != nullptr)
+        {
+            // Set the requested api name as the highest (and only) user priority in the registry.
+            // Atom will select this api at startup as long as it exists and nothing was passed in via command-line.
+            // If the passed-in apiName is empty, just let Atom use its standard default priorities for api selection.
+            // If the passed-in apiName doesn't match one supported by Atom on this platform, Atom will ignore it and use
+            // its standard default priorities as well.
+            if (!apiName.empty())
+            {
+                AZStd::vector<AZStd::string> factoriesPriority;
+                factoriesPriority.emplace_back(apiName);
+                registry->SetObject("/O3DE/Atom/RHI/FactoryManager/factoriesPriority", factoriesPriority);
+            }
+
+            registry->Set(m_graphicsApiKey.c_str(), apiName);
+        }
+    }
+
+    uint8_t MultiplayerSampleUserSettings::GetMasterVolume()
+    {
+        // Default to full volume (100)
+        uint64_t masterVolume = 100;
+
+        if (auto* registry = AZ::SettingsRegistry::Get(); registry != nullptr)
+        {
+            registry->Get(masterVolume, m_masterVolumeKey.c_str());
+        }
+
+        // Make sure any hand-edited registry values stay within a valid range.
+        return AZStd::clamp(aznumeric_cast<uint8_t>(masterVolume), aznumeric_cast<uint8_t>(0), aznumeric_cast<uint8_t>(100));
+    }
+
+    void MultiplayerSampleUserSettings::SetMasterVolume(uint8_t masterVolume)
+    {
+        if (auto* registry = AZ::SettingsRegistry::Get(); registry != nullptr)
+        {
+            // Send a request to the audio system to change the master volume.
+            auto audioSystem = AZ::Interface<Audio::IAudioSystem>::Get();
+            if (audioSystem)
+            {
+                Audio::TAudioObjectID rtpcId = audioSystem->GetAudioRtpcID("Volume_Master");
+
+                if (rtpcId != INVALID_AUDIO_CONTROL_ID)
+                {
+                    Audio::ObjectRequest::SetParameterValue setParameter;
+                    setParameter.m_audioObjectId = INVALID_AUDIO_OBJECT_ID;
+                    setParameter.m_parameterId = rtpcId;
+                    // Master volume in the audio system is expected to be 0.0 (min) - 1.0 (max), but we're using 0 - 100 as integers,
+                    // so convert it from 0 - 100 to the 0 - 1 range.
+                    setParameter.m_value = masterVolume / 100.0f;
+                    AZ::Interface<Audio::IAudioSystem>::Get()->PushRequest(AZStd::move(setParameter));
+                }
+            }
+
+            registry->Set(m_masterVolumeKey.c_str(), aznumeric_cast<uint64_t>(masterVolume));
+        }
+    }
+
+    int16_t MultiplayerSampleUserSettings::GetTextureQuality()
+    {
+        int64_t textureQuality = 1;
+
+        if (auto* registry = AZ::SettingsRegistry::Get(); registry != nullptr)
+        {
+            registry->Get(textureQuality, m_textureQualityKey.c_str());
+        }
+
+        return AZStd::clamp(aznumeric_cast<int16_t>(textureQuality), aznumeric_cast<int16_t>(0), aznumeric_cast<int16_t>(10));
+    }
+
+    void MultiplayerSampleUserSettings::SetTextureQuality(int16_t textureQuality)
+    {
+        if (auto* registry = AZ::SettingsRegistry::Get(); registry != nullptr)
+        {
+            if (auto* imageSystem = AZ::RPI::ImageSystemInterface::Get())
+            {
+                AZ::Data::Instance<AZ::RPI::StreamingImagePool> pool = imageSystem->GetSystemStreamingPool();
+                pool->SetMipBias(textureQuality);
+            }
+
+            registry->Set(m_textureQualityKey.c_str(), aznumeric_cast<int64_t>(textureQuality));
+        }
+    }
+
+    void MultiplayerSampleUserSettings::Save()
+    {
+        AZ::IO::FixedMaxPath userSettingsSavePath = m_userSettingsPath;
+        userSettingsSavePath.ReplaceExtension("setreg.tmp");
+
+        constexpr AZ::IO::OpenMode openMode = AZ::IO::OpenMode::ModeWrite | AZ::IO::OpenMode::ModeCreatePath;
+
+        // Write to a temporary file and then move the file to the final location
+        if (AZ::IO::SystemFileStream userSettingsStream(userSettingsSavePath.c_str(), openMode); userSettingsStream.IsOpen())
+        {
+            auto settingsRegistry = AZ::SettingsRegistry::Get();
+
+            // Remove the .tmp extension from the user settings path
+            // This results in the final path where the settings will actually be saved
+            userSettingsSavePath.ReplaceExtension();
+            AZ::SettingsRegistryMergeUtils::DumperSettings dumperSettings;
+            dumperSettings.m_prettifyOutput = true;
+            if (AZ::SettingsRegistryMergeUtils::DumpSettingsRegistryToStream(
+                *settingsRegistry, BaseRegistryKey, userSettingsStream, dumperSettings))
+            {
+                // Use SystemFile::Rename to move the file to the final destination
+                userSettingsStream.Close();
+                bool renameSuccess = AZ::IO::SystemFile::Rename(userSettingsStream.GetFilename(), userSettingsSavePath.c_str(), true);
+                AZ_Error("UserSettings", renameSuccess, 
+                    "Renaming '%s' to '%s' failed.", userSettingsStream.GetFilename(), userSettingsSavePath.c_str());
+            }
+        }
+    }
+
+} // namespace MultiplayerSample

+ 90 - 0
Gem/Code/Source/UserSettings/MultiplayerSampleUserSettings.h

@@ -0,0 +1,90 @@
+/*
+ * Copyright (c) Contributors to the Open 3D Engine Project.
+ * For complete copyright and license terms please see the LICENSE at the root of this distribution.
+ *
+ * SPDX-License-Identifier: Apache-2.0 OR MIT
+ *
+ */
+
+#pragma once
+#include <AzCore/EBus/EBus.h>
+#include <AzCore/IO/Path/Path.h>
+
+namespace MultiplayerSample
+{
+    // This provides a way to get/set every user setting that MultiplayerSample supports, and to save the user settings file.
+    // Getting the values pulls them out of the saved user settings data, and setting the values both sets them in the user
+    // settings and communicates the change to the appropriate part of the game engine to make the change take effect.
+    class MultiplayerSampleUserSettingsRequests
+        : public AZ::EBusTraits
+    {
+    public:
+        static const AZ::EBusHandlerPolicy HandlerPolicy = AZ::EBusHandlerPolicy::Single;
+
+        virtual ~MultiplayerSampleUserSettingsRequests() = default;
+
+        // Load the user settings and refresh the game engine based on the settings. They automatically get loaded and applied
+        // on launcher startup, but this might need to be called to refresh the settings after connecting to the server and loading
+        // the level in case any engine systems get reset by server cvars and level data.
+        virtual void Load() = 0;
+
+        // Save the user settings file out to disk.
+        virtual void Save() = 0;
+
+        // Change the default graphics API between dx12/vulkan/metal/null on the next restart of the game.
+        virtual AZStd::string GetGraphicsApi() = 0;
+        virtual void SetGraphicsApi(const AZStd::string& apiName) = 0;
+
+        // Change the master volume from 0 - 100.
+        virtual uint8_t GetMasterVolume() = 0;
+        virtual void SetMasterVolume(uint8_t masterVolume) = 0;
+
+        // Change the texture quality. 0 = highest quality (highest mipmap), N = lowest quality (lowest mipmap).
+        // There's no well-defined value for lowest quality so we'll just arbitrarily cap it at 6 (64x64 if mip 0 is 4096x4096). 
+        // Anything lower doesn't really provide any benefit.
+        virtual int16_t GetTextureQuality() = 0;
+        virtual void SetTextureQuality(int16_t textureQuality) = 0;
+    };
+
+    using MultiplayerSampleUserSettingsRequestBus = AZ::EBus<MultiplayerSampleUserSettingsRequests>;
+
+    // This implements the bus provided above. The user settings get auto-loaded at construction and auto-saved at destruction,
+    // though saves can also be triggered at other times as well. Because one of the settings is the default graphics API, these
+    // settings need to be loaded before system components are initialized because the Atom system components load the graphics
+    // API. All of the other settings are changeable at any time and would have allowed this class to get created later in the
+    // boot process.
+    class MultiplayerSampleUserSettings : public MultiplayerSampleUserSettingsRequestBus::Handler
+    {
+    public:
+        MultiplayerSampleUserSettings();
+        ~MultiplayerSampleUserSettings() override;
+
+        void Load() override;
+        void Save() override;
+
+        AZStd::string GetGraphicsApi() override;
+        void SetGraphicsApi(const AZStd::string& apiName) override;
+
+        uint8_t GetMasterVolume() override;
+        void SetMasterVolume(uint8_t masterVolume) override;
+
+        int16_t GetTextureQuality() override;
+        void SetTextureQuality(int16_t textureQuality) override;
+    private:
+        using FixedString = AZStd::fixed_string<256>;
+
+        // The base registry key that all our user settings will live underneath.
+        // We keep them separate from the rest of the registry hierarchy to ensure that users can't
+        // edit their settings file by hand to overwrite any other registry keys that weren't intentionally exposed.
+        static inline constexpr FixedString BaseRegistryKey = "/O3DE/MultiplayerSample/User/Settings";
+
+        // These keep track of the specific registry keys used for each setting.
+        const FixedString m_graphicsApiKey;
+        const FixedString m_textureQualityKey;
+        const FixedString m_masterVolumeKey;
+
+        // The path to the user settings file.
+        AZ::IO::FixedMaxPath m_userSettingsPath;
+    };
+
+} // namespace MultiplayerSample

+ 3 - 0
Gem/Code/multiplayersample_shared_files.cmake

@@ -6,5 +6,8 @@
 #
 #
 
 
 set(FILES
 set(FILES
+
     Source/MultiplayerSampleModule.cpp
     Source/MultiplayerSampleModule.cpp
+    Source/UserSettings/MultiplayerSampleUserSettings.h
+    Source/UserSettings/MultiplayerSampleUserSettings.cpp
 )
 )