Frontend menu + settings + saves foundation

Netcode frontend pattern: UITK main menu / pause / settings (MenuUi + controllers), on-demand world lifecycle (WorldLauncher/SessionRunner), GameBootstrap menu branch; Graphics/Audio settings (SettingsService/GameVolume); single-slot save foundation (SaveData/SaveService, born-correct load at director spawn, autosave on Siege->Calm + quit); RuntimePanelSettings + theme; BuildTool menu; 10 EditMode tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 15:05:36 -07:00
parent f3f65bccbf
commit f31ffe910b
56 changed files with 1744 additions and 8 deletions
@@ -0,0 +1,71 @@
using System;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Serializable client settings model (Graphics + Audio) persisted to JSON by <see cref="SettingsService"/>.
/// Flat fields only (an enum stored as an int) so <c>JsonUtility</c> round-trips it without Newtonsoft.
/// <see cref="Version"/> gates forward-compatible migration. Purely client-local — never replicated (the
/// server has no opinion on a player's resolution or volume).
/// </summary>
[Serializable]
public struct GameSettings
{
public const int CurrentVersion = 1;
public int Version;
// ---- Graphics ----
public int ResWidth;
public int ResHeight;
public int RefreshHz; // 0 = platform default
public int FullScreenMode; // (int)UnityEngine.FullScreenMode (0 Exclusive,1 FullScreenWindow,2 Maximized,3 Windowed)
public int QualityLevel;
public int VSync; // QualitySettings.vSyncCount: 0/1/2
public int TargetFps; // Application.targetFrameRate: -1 = uncapped
// ---- Audio (0..1) ----
public float Master;
public float Music;
public float Sfx;
/// <summary>Sensible defaults derived from the current display + active quality level.</summary>
public static GameSettings Defaults()
{
var r = Screen.currentResolution;
return new GameSettings
{
Version = CurrentVersion,
ResWidth = r.width > 0 ? r.width : 1920,
ResHeight = r.height > 0 ? r.height : 1080,
RefreshHz = 0,
FullScreenMode = (int)UnityEngine.FullScreenMode.FullScreenWindow,
QualityLevel = QualitySettings.GetQualityLevel(),
VSync = 1,
TargetFps = -1,
Master = 1f,
Music = 1f,
Sfx = 1f,
};
}
/// <summary>Clamp every field into a safe range (defensive against hand-edited / corrupt JSON).</summary>
public GameSettings Clamped()
{
var s = this;
s.ResWidth = Mathf.Clamp(s.ResWidth <= 0 ? 1920 : s.ResWidth, 640, 7680);
s.ResHeight = Mathf.Clamp(s.ResHeight <= 0 ? 1080 : s.ResHeight, 480, 4320);
s.RefreshHz = Mathf.Max(0, s.RefreshHz);
s.FullScreenMode = Mathf.Clamp(s.FullScreenMode, 0, 3);
int qn = (QualitySettings.names != null && QualitySettings.names.Length > 0) ? QualitySettings.names.Length : 1;
s.QualityLevel = Mathf.Clamp(s.QualityLevel, 0, qn - 1);
s.VSync = Mathf.Clamp(s.VSync, 0, 2);
s.TargetFps = s.TargetFps <= 0 ? -1 : Mathf.Clamp(s.TargetFps, 20, 1000);
s.Master = Mathf.Clamp01(s.Master);
s.Music = Mathf.Clamp01(s.Music);
s.Sfx = Mathf.Clamp01(s.Sfx);
return s;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1b7e08f285d06e4429849d6ef4d7b730
@@ -0,0 +1,33 @@
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Client-only audio volume BUSES, read by the procedural-audio presentation systems
/// (<see cref="AmbientAudioSystem"/> = Music bus; <see cref="CombatFeedbackSystem"/> +
/// <see cref="WorldFeedbackSystem"/> = Sfx bus). MASTER is applied separately as
/// <c>AudioListener.volume</c> (a global listener gain) by <see cref="SettingsService"/>, so the per-call
/// multipliers here are the per-bus trims ONLY — never multiply by master again or it double-attenuates.
/// <para>
/// A plain static (not an <c>IComponentData</c>) so the Burst-free managed presentation systems read it with
/// zero ECS plumbing. NOT named <c>AudioSettings</c> — that collides with <c>UnityEngine.AudioSettings</c>.
/// Reset on play-enter (<see cref="RuntimeInitializeLoadType.SubsystemRegistration"/>) so a fast-enter-playmode
/// domain reload never carries a stale value into a fresh session.
/// </para>
/// </summary>
public static class GameVolume
{
/// <summary>Music/ambience bus trim (0..1). Applied by AmbientAudioSystem.</summary>
public static float Music = 1f;
/// <summary>SFX bus trim (0..1). Applied by Combat/World feedback systems.</summary>
public static float Sfx = 1f;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetStatics()
{
Music = 1f;
Sfx = 1f;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a41fe49c050b2ae4b869edd52ddb716e
@@ -0,0 +1,116 @@
using System;
using System.IO;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Client-local settings persistence + application. Loads <c>settings.json</c> from
/// <c>Application.persistentDataPath</c> at boot (<see cref="RuntimeInitializeOnLoadMethod"/>) and applies
/// Graphics (Screen / QualitySettings / targetFrameRate) + Audio (<c>AudioListener.volume = Master</c>;
/// <see cref="GameVolume"/> Music/Sfx bus trims). The single source of truth for the UITK
/// <c>SettingsScreen</c> shared by the main menu + the in-game pause overlay. Saves are atomic
/// (temp file + <c>File.Replace</c>). <c>JsonUtility</c> keeps it asmdef-ref-free. Never replicated.
/// </summary>
public static class SettingsService
{
public static GameSettings Current { get; private set; } = GameSettings.Defaults();
static string FilePath => Path.Combine(Application.persistentDataPath, "settings.json");
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
static void Boot()
{
Load();
Apply(Current);
}
/// <summary>Read settings from disk (or defaults if absent/corrupt). Returns the loaded value.</summary>
public static GameSettings Load()
{
try
{
if (File.Exists(FilePath))
{
var json = File.ReadAllText(FilePath);
var s = JsonUtility.FromJson<GameSettings>(json);
if (s.Version != GameSettings.CurrentVersion)
s = Migrate(s);
Current = s.Clamped();
}
else
{
Current = GameSettings.Defaults();
}
}
catch (Exception e)
{
Debug.LogWarning($"[SettingsService] Load failed ({e.Message}); using defaults.");
Current = GameSettings.Defaults();
}
return Current;
}
/// <summary>Clamp + version-stamp + atomically write to disk; updates <see cref="Current"/>.</summary>
public static void Save(GameSettings settings)
{
settings = settings.Clamped();
settings.Version = GameSettings.CurrentVersion;
Current = settings;
try
{
var json = JsonUtility.ToJson(settings, true);
var tmp = FilePath + ".tmp";
File.WriteAllText(tmp, json);
if (File.Exists(FilePath)) File.Replace(tmp, FilePath, null);
else File.Move(tmp, FilePath);
}
catch (Exception e)
{
Debug.LogWarning($"[SettingsService] Save failed: {e.Message}");
}
}
/// <summary>Apply settings to the live engine. Master rides AudioListener.volume; buses are per-call trims.</summary>
public static void Apply(GameSettings s)
{
s = s.Clamped();
// Graphics
QualitySettings.SetQualityLevel(s.QualityLevel, true);
QualitySettings.vSyncCount = s.VSync;
var mode = (FullScreenMode)s.FullScreenMode;
if (s.RefreshHz > 0)
Screen.SetResolution(s.ResWidth, s.ResHeight, mode, new RefreshRate { numerator = (uint)s.RefreshHz, denominator = 1 });
else
Screen.SetResolution(s.ResWidth, s.ResHeight, mode);
Application.targetFrameRate = s.TargetFps; // -1 = uncapped (ignored while vSync > 0)
// Audio
AudioListener.volume = s.Master;
GameVolume.Music = s.Music;
GameVolume.Sfx = s.Sfx;
}
/// <summary>Convenience for the settings UI: persist then apply.</summary>
public static void SaveAndApply(GameSettings s)
{
Save(s);
Apply(Current);
}
// Forward-compatible migration: fill from current defaults, preserve any recognizable old values.
// Additive-only as the schema grows (never throws on an unknown version).
static GameSettings Migrate(GameSettings old)
{
var def = GameSettings.Defaults();
if (old.ResWidth > 0) def.ResWidth = old.ResWidth;
if (old.ResHeight > 0) def.ResHeight = old.ResHeight;
if (old.Master > 0f) def.Master = old.Master;
if (old.Music > 0f) def.Music = old.Music;
if (old.Sfx > 0f) def.Sfx = old.Sfx;
def.Version = GameSettings.CurrentVersion;
return def;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3daf657b3863e6e4fa02aa046a1b074e