29e90a5008
Teaches the deep, interlocking loop — especially the inverted win condition (you win by CLEARING EXPEDITIONS, not by surviving base sieges; DR-042/DR-043). - OnboardingSystem: client-only observe-only PresentationSystemGroup overlay (own UIDocument @ sortingOrder 60), soft-gated 10-beat coach-mark sequence with a world-space ▶ pointer; never mutates sim / never destroys a ghost. - OnboardingStepMath: pure, unit-tested step machine (snapshot + IsSatisfied + scheme-aware prompts + pointer kinds + persisted-mask helpers). - HowToPlayPanel: tabbed reference card (Controls / The Loop / Build / Threats / Win-Lose), reachable from the main menu and the pause overlay. - Per-client client-local state in GameSettings (TutorialHints + OnboardingMask bitmask, additive) — a Join client keeps its own; a host save-wipe never re-teaches. Settings toggle + menu "Replay Tutorial". - Dev "Force Each Launch" toggle (GameSettings.ForceOnboardingEachLaunch): SettingsService.Boot wipes the mask + forces hints on in-memory every launch so the tutorial always replays fresh. - HudSystem suppresses its own location hint while onboarding is active (single prompt voice), via OnboardingState + [UpdateAfter(OnboardingSystem)]. Validated green: 20/20 EditMode; Play smoke confirmed overlay render, clean U+25B6 pointer glyph, no system sort-cycle, and the force-wipe end-to-end. Docs: DR-043 + session log; reusable lesson archived in the build-gotchas note. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
136 lines
5.9 KiB
C#
136 lines
5.9 KiB
C#
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();
|
|
// DEV convenience ("Force Each Launch", Settings → Onboarding): wipe the persisted completed-step mask at
|
|
// EVERY boot — each editor Play-enter / each built-player launch runs this hook — so the first-run
|
|
// coach-marks always replay from the top, and force hints on so they actually show. In-memory only (the
|
|
// wipe is NOT written back to disk); OnboardingSystem re-persists progress as the player advances, and the
|
|
// next launch wipes it again. Toggle it off to return to normal once-only first-run behaviour.
|
|
if (Current.ForceOnboardingEachLaunch != 0)
|
|
{
|
|
var s = Current;
|
|
s.OnboardingMask = 0;
|
|
s.TutorialHints = 1;
|
|
Current = s;
|
|
}
|
|
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)
|
|
{
|
|
// Preserve EVERY recognized field from the old save (Load() Clamps the result, fixing any 0/garbage),
|
|
// and seed ONLY the genuinely-new fields — a v1 file deserializes those to 0. Migrating from a fresh
|
|
// Defaults() instead would silently reset graphics fields (display mode / quality / v-sync / fps cap /
|
|
// refresh rate) that v1 already carried — a regression the version bump would otherwise surface.
|
|
var s = old;
|
|
if (old.Version < 2)
|
|
{
|
|
// v1 had no onboarding fields. An existing settings.json ⇒ a returning player who already played a
|
|
// pre-onboarding build, so mark every coach-mark step complete (dormant); hints stay on so
|
|
// "Replay Tutorial" (which clears OnboardingMask) still re-arms.
|
|
s.TutorialHints = 1;
|
|
s.OnboardingMask = int.MaxValue;
|
|
}
|
|
s.Version = GameSettings.CurrentVersion;
|
|
return s;
|
|
}
|
|
}
|
|
}
|