Game/GameSettings.cs
namespace BlockParty;
/// <summary>Whose scores the high-score board shows: everyone, or only the player's Steam friends.</summary>
public enum LeaderboardScope
{
Global,
Friends,
}
/// <summary>How the workshop browser orders results. Values follow the browser's cycle order
/// (Newest -> Top -> Trending) so the SORT button can advance with a modulo step.</summary>
public enum WorkshopSortMode
{
Newest,
TopVoted,
Trending,
}
/// <summary>
/// Player-tunable, persisted settings. The volume sliders plus the high-score board's
/// last-used scope (so the leaderboard opens the way you left it). Values are kept in
/// slider-space (0..100) so the UI binds to them directly; the audio layer converts to a 0..1
/// factor when applying. Mirrors the approach in sbox-ss2's <c>GameSettingsSystem</c>, kept
/// minimal for BlockParty.
/// </summary>
public sealed class GameSettings
{
/// <summary>Campaign-data version last written for this player. A mismatch with
/// <see cref="LevelProgress.PROGRESS_VERSION"/> wipes campaign progression <em>and</em> character
/// unlocks (beaten levels, unlocked/selected character, seen ability help) on load; volume,
/// leaderboard, input and other preferences are preserved. See <see cref="Settings.Load"/>.</summary>
public int ProgressVersion { get; set; }
/// <summary>When true, show the elapsed run timer in live runs and on the score tally screen.
/// Replays have their own transport clock, so this stays hidden there. Default off.</summary>
public bool ShowTimer { get; set; } = false;
public const float RestartButtonDelayStep = 0.05f;
/// <summary>Seconds to hold Restart during a live run. Zero restarts instantly without an overlay.
/// Clamped and snapped on assignment (including JSON load) to 0..1 in 0.05-second steps.</summary>
public float RestartButtonDelay
{
get => _restartButtonDelay;
set => _restartButtonDelay = float.IsFinite( value )
? MathF.Round( Math.Clamp( value, 0f, 1f ) / RestartButtonDelayStep ) * RestartButtonDelayStep
: 0.5f;
}
private float _restartButtonDelay = 0.5f;
/// <summary>Camera-screenshake intensity multiplier. Slider-space 0..100 (the render layer converts
/// to a 0..1 factor — see <see cref="GameStage"/>); scales every camera shake. 0 disables shake
/// entirely. Default 100 (full shake).</summary>
public float Screenshake { get; set; } = 50f;
/// <summary>Overall volume; scales both SFX and music.</summary>
public float MasterVolume { get; set; } = 60f;
/// <summary>Sound-effects volume. Default 100.</summary>
public float SfxVolume { get; set; } = 100f;
/// <summary>Music volume. Default 60 (the audio layer scales this up by <see cref="Audio.MusicVolumeScale"/>).</summary>
public float MusicVolume { get; set; } = 60f;
/// <summary>Controller-vibration strength (0 = off). Slider-space 0..100; the haptics layer
/// converts to a 0..1 scale — see <see cref="Haptics"/>. Default 75.</summary>
public float VibrationStrength { get; set; } = 75f;
/// <summary>Whether the normal high-score board is limited to friends. Default everyone. Applies
/// only to the normal High Scores page, not the Daily Challenge board.</summary>
public LeaderboardScope LeaderboardScope { get; set; } = LeaderboardScope.Global;
/// <summary>Sort order last used in the workshop browser, so it reopens the way you left it.
/// Default newest-first.</summary>
public WorkshopSortMode WorkshopSort { get; set; } = WorkshopSortMode.Newest;
/// <summary>Integer upscale of the 240px arena used by GIF export (1x = 240px, 3x = 720px), picked
/// from the export overlay's SIZE dropdown. The setter clamps to 1..<see cref="GifExporter.MaxScale"/>
/// (JSON load goes through it too), so readers never re-validate. Default 3x.</summary>
public int GifScale
{
get => _gifScale;
set => _gifScale = GifExporter.ClampScale( value );
}
private int _gifScale = GifExporter.DefaultScale;
/// <summary>Which level's board the High Scores page shows (see <see cref="Levels"/>). Null =
/// classic. Set from the page's LEVEL dropdown and by finishing a run on a level, so the board
/// opens on the level just played.</summary>
public string LeaderboardLevelId { get; set; }
/// <summary>Optional character filter for the normal High Scores page. Null shows the overall
/// per-level board. Kept separate from <see cref="SelectedCharacterId"/> so browsing scores never
/// changes the character used by level select or daily challenges.</summary>
public string LeaderboardCharacterId { get; set; }
/// <summary>Which character the player has chosen to play as (see <see cref="Characters"/>).
/// Null = Original. Set from the title-screen character picker and used when a fresh
/// run is launched. Persisted so the choice survives across sessions.</summary>
public string SelectedCharacterId { get; set; }
/// <summary>Character ids the player has unlocked. Null (missing/corrupt file) loads as the
/// starter roster; see <see cref="Settings.Load"/>.</summary>
public System.Collections.Generic.List<string> UnlockedCharacterIds { get; set; }
/// <summary>Character ids the player has selected at least once. Original starts selected.</summary>
public System.Collections.Generic.List<string> EverSelectedCharacterIds { get; set; }
= new() { Characters.OriginalId };
/// <summary>Characters that have been unlocked but not yet shown to the player as a "YOU UNLOCKED"
/// reveal, oldest first. Persisted so a reveal still owed when the game is closed (or when a won run
/// was abandoned before its score tally opened) is delivered on the next menu screen instead of
/// being lost. Managed via <see cref="CharacterProgress"/>; presented by <see cref="UnlockReveal"/>.</summary>
public System.Collections.Generic.List<string> PendingUnlockRevealIds { get; set; } = new();
/// <summary>Characters whose automatic first-play ability help has already been shown. This is
/// independent of unlock state because authored levels can force a character the player does not own.</summary>
public System.Collections.Generic.List<string> SeenCharacterHelpIds { get; set; } = new();
/// <summary>The player has manually opened the in-run "?" character help at least once. Once true,
/// the help button stops its "this exists" nudge pulse forever (until a progress reset). Managed via
/// <see cref="CharacterProgress"/>.</summary>
public bool CharacterHelpButtonUsed { get; set; }
/// <summary>Ids (see <see cref="LevelDef.Id"/>) of levels the player has beaten by reaching max
/// phase on every block, driving map progression/unlocks on the level-select screen. Managed via
/// <see cref="LevelProgress"/>.</summary>
public System.Collections.Generic.List<string> BeatenLevelIds { get; set; } = new();
/// <summary>The map node the player last had highlighted on the level-select screen (a
/// <see cref="LevelDef.Id"/>). The map reopens focused on this node. Null = start on the first
/// major level. Managed via <see cref="LevelProgress"/>.</summary>
public string LastSelectedLevelId { get; set; }
/// <summary>Level ids that were selectable the last time the level-select stage was entered. Null
/// identifies a profile written before unlock-reveal tracking existed.</summary>
public System.Collections.Generic.List<string> LastSeenUnlockedLevelIds { get; set; }
/// <summary>Characters that have beaten the capstone level (<see cref="Achievements.CapstoneLevelId"/>),
/// driving the "beat it with every character" achievement. Named for the ROLE rather than the level
/// so re-pointing the achievement at a different level doesn't leave a misnamed field; the stored
/// ids then simply no longer apply. Ids that leave <see cref="Characters.All"/> are ignored on read
/// rather than pruned, so a character retired and restored keeps its win.</summary>
public System.Collections.Generic.List<string> CapstoneWinCharacterIds { get; set; } = new();
/// <summary>Names of <see cref="BlockType"/>s the player has brought to max phase (enum NAMES, not
/// indices, so reordering the enum can't corrupt the record — a renamed type simply orphans its
/// entry). Drives the block-type collection achievement; counted against the CURRENT enum on read,
/// so orphans can't inflate the numerator. Managed via <see cref="Achievements.RecordMaxPhasedTypes"/>.</summary>
public System.Collections.Generic.List<string> MaxPhasedBlockTypes { get; set; } = new();
/// <summary>Identity keys (see <see cref="GameManager.CurrentReplayKey"/>) of replays already
/// counted toward the "GIF of N different replays" achievement, so re-exporting the same run can't
/// count twice. Deliberately NOT cleared by <see cref="Settings.ResetPlayerProgress"/> — it's
/// achievement bookkeeping rather than progression, and clearing it would let a wipe inflate the
/// stat. Trimmed to <see cref="Achievements.MaxTrackedGifReplays"/> most-recent entries so a
/// prolific exporter can't grow the settings file without bound.</summary>
public System.Collections.Generic.List<string> GifExportedReplayKeys { get; set; } = new();
/// <summary>Lifetime totals behind the counting stat achievements, keyed by stat name. The DURABLE
/// source of truth: each goes out whole via SetValue under HIGHEST aggregation (see
/// <c>Achievements.SubmitTally</c>), so a submission lost to a network drop heals on the next
/// submit or boot instead of vanishing. Achievement bookkeeping like the GIF keys: never cleared
/// by a progress reset.</summary>
public System.Collections.Generic.Dictionary<string, long> AchievementTallies { get; set; } = new();
/// <summary>MANUAL achievement idents earned on this machine, re-sent by
/// <c>Achievements.CheckProgress</c> every boot — unlocking is idempotent and the backend drops
/// repeats, so an unlock request lost offline heals once a later boot gets through. Achievement
/// bookkeeping: never cleared by a progress reset.</summary>
public System.Collections.Generic.List<string> EarnedManualAchievements { get; set; } = new();
}
/// <summary>
/// Static accessor + JSON persistence for <see cref="GameSettings"/>. Stored in the game's
/// per-user data folder via <see cref="FileSystem.Data"/>; loaded lazily on first access and
/// re-saved (debounced) by the options UI whenever a value changes.
/// </summary>
public static class Settings
{
public const string FilePath = "blockparty_settings.json";
private static GameSettings _current;
public static GameSettings Current
{
get
{
if ( _current is null ) Load();
return _current;
}
}
/// <summary>Load settings from disk, falling back to defaults if the file is missing/invalid.</summary>
public static void Load()
{
_current = FileSystem.Data.ReadJsonOrDefault<GameSettings>( FilePath, new GameSettings() );
_current ??= new GameSettings();
// Sanity-filter the unlocked roster: drop ids that don't resolve to a character, and make
// sure at least the starter is present (also covers a missing/corrupt file).
_current.UnlockedCharacterIds = ( _current.UnlockedCharacterIds ?? new() { Characters.OriginalId } )
.Where( id => Characters.TryGet( id, out _ ) )
.Distinct()
.ToList();
if ( _current.UnlockedCharacterIds.Count == 0 )
_current.UnlockedCharacterIds.Add( Characters.OriginalId );
_current.EverSelectedCharacterIds = ( _current.EverSelectedCharacterIds
?? new() { Characters.OriginalId } )
.Where( id => Characters.TryGet( id, out _ ) )
.Distinct()
.ToList();
if ( !_current.EverSelectedCharacterIds.Contains( Characters.OriginalId ) )
_current.EverSelectedCharacterIds.Add( Characters.OriginalId );
// Owed reveals must still name a real, still-unlocked character — a renamed/removed character
// or a debug re-lock would otherwise leave an undeliverable entry popping up on every launch.
_current.PendingUnlockRevealIds = ( _current.PendingUnlockRevealIds ?? new() )
.Where( id => Characters.TryGet( id, out _ ) && _current.UnlockedCharacterIds.Contains( id ) )
.Distinct()
.ToList();
_current.SeenCharacterHelpIds = ( _current.SeenCharacterHelpIds ?? new() )
.Where( id => Characters.TryGet( id, out _ ) )
.Distinct()
.ToList();
// A null here (hand-edited or truncated file) would throw out of the progress-version reset
// below — and since the new version is only stamped by that reset, the throw would repeat on
// every launch and make the profile unloadable.
_current.BeatenLevelIds ??= new();
// Same hand-edited/truncated-file hardening for the achievement bookkeeping lists — a null here
// would throw out of Achievements.CheckProgress on boot (capstone) or the next GIF export.
_current.CapstoneWinCharacterIds ??= new();
_current.GifExportedReplayKeys ??= new();
_current.MaxPhasedBlockTypes ??= new();
_current.AchievementTallies ??= new();
_current.EarnedManualAchievements ??= new();
if ( _current.ProgressVersion != LevelProgress.PROGRESS_VERSION )
{
ResetPlayerProgress();
Save();
}
CharacterProgress.RepairSelectedCharacter();
}
public static void Save()
{
FileSystem.Data.WriteJson( FilePath, Current );
}
/// <summary>Wipe local campaign progression while preserving player preferences, replays and daily
/// challenge history. Restores the starter character and focuses the map on Tutorial.</summary>
[ConCmd( "reset_player_progress" )]
public static void ResetPlayerProgressCmd()
{
if ( !Game.IsEditor ) return;
ResetPlayerProgress();
Save();
if ( GameManager.Instance?.Stage is LevelSelectStage levelSelect )
levelSelect.RefreshProgress();
Log.Info( "[BlockParty] Reset local player progress: Original and Tutorial are unlocked." );
}
private static void ResetPlayerProgress()
{
Current.ProgressVersion = LevelProgress.PROGRESS_VERSION;
Current.BeatenLevelIds.Clear();
Current.LastSelectedLevelId = Levels.TutorialId;
Current.LastSeenUnlockedLevelIds = new() { Levels.TutorialId };
CharacterProgress.ResetToOriginal();
}
}