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:
adminandClaude Opus 4.8 committed 2026-07-03 15:30:44 +02:00
1 parent f586ef7a15
commit ba7f3c2e89
9 files changed
+290 -27

No files matched your search

+83
View File
@@ -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
+82
View File
@@ -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
+3 -27
View File
@@ -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);
}
}