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 <noreply@anthropic.com>
This commit is contained in:
1 parent
f586ef7a15
commit
ba7f3c2e89
9 files changed
+290
-27
No files matched your search
@@ -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: {}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 4c2f5be6ec68f3e4aa5ef0471d7f2e22
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 24100000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -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) + "%";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: f41ca303b8c3d3b40a1a2bfa7a0e2546
|
||||||
@@ -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 <persistentDataPath>/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<Data>(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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 15e004012996a8b42b4b10fea93065b0
|
||||||
@@ -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<AudioMixer>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 1f30b7ac7b89413478b22e6a7bc4f9b7
|
||||||
@@ -2,40 +2,16 @@ using UnityEngine;
|
|||||||
|
|
||||||
/// Global mouse look-around sensitivity multiplier, shared by every camera
|
/// Global mouse look-around sensitivity multiplier, shared by every camera
|
||||||
/// controller (FreeCam, FirstPerson, ThirdPersonOrbit). 1 = unchanged baseline.
|
/// 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 static class MouseSensitivity
|
||||||
{
|
{
|
||||||
public const float Default = 1f;
|
public const float Default = 1f;
|
||||||
public const float Min = 0.1f;
|
public const float Min = 0.1f;
|
||||||
public const float Max = 4f;
|
public const float Max = 4f;
|
||||||
|
|
||||||
const string PrefKey = "Settings.MouseSensitivity";
|
|
||||||
|
|
||||||
static bool _loaded;
|
|
||||||
static float _multiplier = Default;
|
|
||||||
|
|
||||||
public static float Multiplier
|
public static float Multiplier
|
||||||
{
|
{
|
||||||
get
|
get => Mathf.Clamp(GameSettings.MouseSensitivity, Min, Max);
|
||||||
{
|
set => GameSettings.MouseSensitivity = Mathf.Clamp(value, Min, Max);
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user