From ba7f3c2e893fa5e4f482c3bb52774366e563aea1 Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Fri, 3 Jul 2026 15:30:44 +0200 Subject: [PATCH] Move settings to Settings.json and add master volume Replace the former per-value PlayerPrefs storage with a shared human-readable Settings.json store (GameSettings). MouseSensitivity keeps its public .Multiplier API and only swaps its backing to the JSON store, so the camera controllers stay untouched. Add a MasterVolume facade that drives the AudioMixer's exposed "MasterVolume" parameter (loaded from Resources, linear<->dB mapping), applied at startup and on change, plus a MasterVolumeSlider binder. The default is the level currently authored on the mixer instead of an arbitrary constant. Co-Authored-By: Claude Opus 4.8 --- Assets/Resources/AudioMixer.mixer | 71 ++++++++++++++++ Assets/Resources/AudioMixer.mixer.meta | 8 ++ .../Components/UI/MasterVolumeSlider.cs | 37 +++++++++ .../Components/UI/MasterVolumeSlider.cs.meta | 2 + Assets/Scripts/Helper/GameSettings.cs | 83 +++++++++++++++++++ Assets/Scripts/Helper/GameSettings.cs.meta | 2 + Assets/Scripts/Helper/MasterVolume.cs | 82 ++++++++++++++++++ Assets/Scripts/Helper/MasterVolume.cs.meta | 2 + Assets/Scripts/Helper/MouseSensitivity.cs | 30 +------ 9 files changed, 290 insertions(+), 27 deletions(-) create mode 100644 Assets/Resources/AudioMixer.mixer create mode 100644 Assets/Resources/AudioMixer.mixer.meta create mode 100644 Assets/Scripts/Components/UI/MasterVolumeSlider.cs create mode 100644 Assets/Scripts/Components/UI/MasterVolumeSlider.cs.meta create mode 100644 Assets/Scripts/Helper/GameSettings.cs create mode 100644 Assets/Scripts/Helper/GameSettings.cs.meta create mode 100644 Assets/Scripts/Helper/MasterVolume.cs create mode 100644 Assets/Scripts/Helper/MasterVolume.cs.meta diff --git a/Assets/Resources/AudioMixer.mixer b/Assets/Resources/AudioMixer.mixer new file mode 100644 index 0000000..e803f64 --- /dev/null +++ b/Assets/Resources/AudioMixer.mixer @@ -0,0 +1,71 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!241 &24100000 +AudioMixerController: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: AudioMixer + m_OutputGroup: {fileID: 0} + m_MasterGroup: {fileID: 24300002} + m_Snapshots: + - {fileID: 24500006} + m_StartSnapshot: {fileID: 24500006} + m_SuspendThreshold: -80 + m_EnableSuspend: 1 + m_UpdateMode: 0 + m_ExposedParameters: + - guid: e1c3ba92ff34ded45b9fb0e7e9d9ff5f + name: MasterVolume + m_AudioMixerGroupViews: + - guids: + - baf4df5f8e456a244865f2490b8be297 + name: View + m_CurrentViewIndex: 0 + m_TargetSnapshot: {fileID: 24500006} +--- !u!243 &24300002 +AudioMixerGroupController: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: Master + m_AudioMixer: {fileID: 24100000} + m_GroupID: baf4df5f8e456a244865f2490b8be297 + m_Children: [] + m_Volume: e1c3ba92ff34ded45b9fb0e7e9d9ff5f + m_Pitch: 55a908e3384754b40ab5380d73216150 + m_Send: 00000000000000000000000000000000 + m_Effects: + - {fileID: 24400004} + m_UserColorIndex: 0 + m_Mute: 0 + m_Solo: 0 + m_BypassEffects: 0 +--- !u!244 &24400004 +AudioMixerEffectController: + m_ObjectHideFlags: 3 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: + m_EffectID: e9eec667a6b554a43b51e2771497e881 + m_EffectName: Attenuation + m_MixLevel: 83ef66f0d6268ba47998027f81758c3b + m_Parameters: [] + m_SendTarget: {fileID: 0} + m_EnableWetMix: 0 + m_Bypass: 0 +--- !u!245 &24500006 +AudioMixerSnapshotController: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: Snapshot + m_AudioMixer: {fileID: 24100000} + m_SnapshotID: 2cd06a6f291b6c8468486aebc1437c6b + m_FloatValues: + e1c3ba92ff34ded45b9fb0e7e9d9ff5f: -6 + m_TransitionOverrides: {} diff --git a/Assets/Resources/AudioMixer.mixer.meta b/Assets/Resources/AudioMixer.mixer.meta new file mode 100644 index 0000000..c83e332 --- /dev/null +++ b/Assets/Resources/AudioMixer.mixer.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4c2f5be6ec68f3e4aa5ef0471d7f2e22 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 24100000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Components/UI/MasterVolumeSlider.cs b/Assets/Scripts/Components/UI/MasterVolumeSlider.cs new file mode 100644 index 0000000..7ea039f --- /dev/null +++ b/Assets/Scripts/Components/UI/MasterVolumeSlider.cs @@ -0,0 +1,37 @@ +using TMPro; +using UnityEngine; +using UnityEngine.UI; + +/// Binds a UI Slider (and optional value label) to the global MasterVolume level. +/// The slider's range is authored in the inspector (0 - 1); this only reads/writes +/// the value so scene-side range edits are respected. +public class MasterVolumeSlider : MonoBehaviour +{ + [SerializeField] private Slider slider; + [Tooltip("Optional TMP label showing the current volume as a percentage (e.g. \"80%\").")] + [SerializeField] private TMP_Text valueLabel; + + void OnEnable() + { + if (!slider) return; + slider.SetValueWithoutNotify(MasterVolume.Volume); + UpdateLabel(MasterVolume.Volume); + slider.onValueChanged.AddListener(OnSliderChanged); + } + + void OnDisable() + { + if (slider) slider.onValueChanged.RemoveListener(OnSliderChanged); + } + + void OnSliderChanged(float value) + { + MasterVolume.Volume = value; + UpdateLabel(value); + } + + void UpdateLabel(float value) + { + if (valueLabel) valueLabel.text = Mathf.RoundToInt(value * 100f) + "%"; + } +} diff --git a/Assets/Scripts/Components/UI/MasterVolumeSlider.cs.meta b/Assets/Scripts/Components/UI/MasterVolumeSlider.cs.meta new file mode 100644 index 0000000..f97abd0 --- /dev/null +++ b/Assets/Scripts/Components/UI/MasterVolumeSlider.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f41ca303b8c3d3b40a1a2bfa7a0e2546 \ No newline at end of file diff --git a/Assets/Scripts/Helper/GameSettings.cs b/Assets/Scripts/Helper/GameSettings.cs new file mode 100644 index 0000000..6dd8271 --- /dev/null +++ b/Assets/Scripts/Helper/GameSettings.cs @@ -0,0 +1,83 @@ +using System; +using System.IO; +using UnityEngine; + +/// Shared JSON-backed store for the player-facing game settings, written human-readable +/// to /Settings.json. Replaces the former per-value PlayerPrefs storage. +/// Loaded lazily on first access and cached in memory (reads are plain field access, so the +/// per-frame Multiplier reads from the camera controllers never touch the disk). Each change is +/// written immediately (settings edits are rare user actions), so a hard-killed build keeps them. +/// +/// The typed domain facades (MouseSensitivity, MasterVolume) own the ranges/clamping and read/write +/// through here; this class stays domain-agnostic and only persists the raw values. +public static class GameSettings +{ + private const string FileName = "Settings.json"; + private const int FileVersion = 1; + + [Serializable] + private class Data + { + public int version = FileVersion; + public float mouseSensitivity = global::MouseSensitivity.Default; + public float masterVolume = global::MasterVolume.Default; + } + + private static Data _data; + private static Data Store => _data ??= Load(); + + private static string SavePath() => Path.Combine(Application.persistentDataPath, FileName); + + public static float MouseSensitivity + { + get => Store.mouseSensitivity; + set + { + if (Mathf.Approximately(Store.mouseSensitivity, value)) return; + Store.mouseSensitivity = value; + Save(); + } + } + + public static float MasterVolume + { + get => Store.masterVolume; + set + { + if (Mathf.Approximately(Store.masterVolume, value)) return; + Store.masterVolume = value; + Save(); + } + } + + private static Data Load() + { + string path = SavePath(); + if (File.Exists(path)) + { + try + { + var data = JsonUtility.FromJson(File.ReadAllText(path)); + if (data != null) return data; + } + catch (Exception e) + { + Debug.LogWarning($"[GameSettings] Loading {FileName} failed, using defaults: {e.Message}"); + } + } + return new Data(); + } + + private static void Save() + { + try + { + Directory.CreateDirectory(Application.persistentDataPath); + File.WriteAllText(SavePath(), JsonUtility.ToJson(Store, true)); + } + catch (Exception e) + { + Debug.LogWarning($"[GameSettings] Saving {FileName} failed: {e.Message}"); + } + } +} diff --git a/Assets/Scripts/Helper/GameSettings.cs.meta b/Assets/Scripts/Helper/GameSettings.cs.meta new file mode 100644 index 0000000..d82fd73 --- /dev/null +++ b/Assets/Scripts/Helper/GameSettings.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 15e004012996a8b42b4b10fea93065b0 \ No newline at end of file diff --git a/Assets/Scripts/Helper/MasterVolume.cs b/Assets/Scripts/Helper/MasterVolume.cs new file mode 100644 index 0000000..e40c772 --- /dev/null +++ b/Assets/Scripts/Helper/MasterVolume.cs @@ -0,0 +1,82 @@ +using UnityEngine; +using UnityEngine.Audio; + +/// Global master audio volume (0 = mute .. 1 = full), applied to the "MasterVolume" exposed +/// parameter of the AudioMixer (loaded from Resources). Persisted via GameSettings (Settings.json). +/// Applied once at startup via a runtime-init hook so the saved level takes effect before the +/// first scene's audio plays, and again on every change. +public static class MasterVolume +{ + public const float Min = 0f; + public const float Max = 1f; + + /// The default is the level currently authored on the mixer (its exposed-parameter value + /// converted back to linear), so a fresh install starts at the mixer's own setting instead of + /// an arbitrary constant. Falls back to FallbackDefault if the mixer/param is unavailable. + public static float Default => TryReadMixerLinear(out float linear) ? linear : .5f; + + // Must match the exposed parameter name authored on the AudioMixer asset. + private const string ExposedParam = "MasterVolume"; + // AudioMixer.mixer lives under a Resources/ folder -> load by name without extension. + private const string MixerResourcePath = "AudioMixer"; + // The exposed param is the master group's attenuation in dB; below this the log curve dives + // to -inf, so -80 dB is the practical silence floor (matches the mixer's own lower bound). + private const float SilenceDb = -80f; + + private static AudioMixer _mixer; + private static AudioMixer Mixer => _mixer ? _mixer : _mixer = Resources.Load(MixerResourcePath); + + public static float Volume + { + get => Mathf.Clamp(GameSettings.MasterVolume, Min, Max); + set + { + float v = Mathf.Clamp(value, Min, Max); + GameSettings.MasterVolume = v; + Apply(v); // set unconditionally: keeps the live mixer in sync even if the stored value was unchanged + } + } + + // A perceptual volume slider is linear 0..1; the mixer wants dB. 20*log10 maps 1 -> 0 dB and + // fades smoothly, with 0 pinned to the silence floor (log10(0) is -inf). + private static float LinearToDb(float linear) => + linear <= Min ? SilenceDb : Mathf.Max(SilenceDb, Mathf.Log10(linear) * 20f); + + // Inverse of LinearToDb: read the mixer's authored dB back into the slider's 0..1 range. + private static float DbToLinear(float db) => + db <= SilenceDb ? Min : Mathf.Clamp(Mathf.Pow(10f, db / 20f), Min, Max); + + // Reads the mixer's current exposed-parameter level (from the active snapshot) as linear 0..1. + // Returns false if the mixer isn't loaded or the parameter isn't exposed, so callers can fall back. + private static bool TryReadMixerLinear(out float linear) + { + var mixer = Mixer; + if (!mixer || !mixer.GetFloat(ExposedParam, out float db)) + { + linear = .5f; + return false; + } + linear = DbToLinear(db); + return true; + } + + private static void Apply(float linear) + { + var mixer = Mixer; + if (!mixer) + { + Debug.LogWarning($"[MasterVolume] AudioMixer '{MixerResourcePath}' not found under a Resources/ folder."); + return; + } + if (!mixer.SetFloat(ExposedParam, LinearToDb(linear))) + Debug.LogWarning($"[MasterVolume] Exposed parameter '{ExposedParam}' not found on the AudioMixer."); + } + + // Push the saved level before any scene audio starts. SetFloat overrides the exposed parameter + // permanently (until ClearFloat), so the start snapshot's authored value is superseded. + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] + private static void ApplyOnStartup() + { + Apply(Volume); + } +} diff --git a/Assets/Scripts/Helper/MasterVolume.cs.meta b/Assets/Scripts/Helper/MasterVolume.cs.meta new file mode 100644 index 0000000..0703537 --- /dev/null +++ b/Assets/Scripts/Helper/MasterVolume.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1f30b7ac7b89413478b22e6a7bc4f9b7 \ No newline at end of file diff --git a/Assets/Scripts/Helper/MouseSensitivity.cs b/Assets/Scripts/Helper/MouseSensitivity.cs index acdb86f..b16f274 100644 --- a/Assets/Scripts/Helper/MouseSensitivity.cs +++ b/Assets/Scripts/Helper/MouseSensitivity.cs @@ -2,40 +2,16 @@ using UnityEngine; /// Global mouse look-around sensitivity multiplier, shared by every camera /// controller (FreeCam, FirstPerson, ThirdPersonOrbit). 1 = unchanged baseline. -/// Persisted per-machine via PlayerPrefs so the setting survives restarts. +/// Persisted via GameSettings (Settings.json) so the setting survives restarts. public static class MouseSensitivity { public const float Default = 1f; public const float Min = 0.1f; public const float Max = 4f; - const string PrefKey = "Settings.MouseSensitivity"; - - static bool _loaded; - static float _multiplier = Default; - public static float Multiplier { - get - { - if (!_loaded) - { - _loaded = true; - _multiplier = Mathf.Clamp(PlayerPrefs.GetFloat(PrefKey, Default), Min, Max); - } - return _multiplier; - } - set - { - float v = Mathf.Clamp(value, Min, Max); - _loaded = true; - if (Mathf.Approximately(v, _multiplier)) return; - _multiplier = v; - PlayerPrefs.SetFloat(PrefKey, v); - // Flush to disk now so the setting survives a hard kill (build), not - // only a clean quit. Settings changes are rare user actions, so the - // extra write is negligible. - PlayerPrefs.Save(); - } + get => Mathf.Clamp(GameSettings.MouseSensitivity, Min, Max); + set => GameSettings.MouseSensitivity = Mathf.Clamp(value, Min, Max); } }