From 270c42d85b2721dc595601fe504da41833125e38 Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Wed, 8 Jul 2026 22:04:54 +0200 Subject: [PATCH] Add host-configurable match settings persisted in settings - GameSettings: 7 match fields (mode, hunter count, hide/seek/resolve durations, radar ping interval/lifetime) with authored defaults - MatchSettingSlider (new): enum-driven panel binder, seeds from settings, saves on change; lifetime label shows one decimal (invariant culture) - MatchManager: ApplyHostMatchSettings() on StartMatch (clamped); phase message now carries ping interval so clients tick correctly Co-Authored-By: Claude Fable 5 --- .../Components/UI/MatchSettingSlider.cs | 100 ++++++++++++++++++ .../Components/UI/MatchSettingSlider.cs.meta | 2 + Assets/Scripts/Helper/GameSettings.cs | 61 +++++++++++ Assets/Scripts/Network/MatchManager.cs | 51 ++++++--- 4 files changed, 200 insertions(+), 14 deletions(-) create mode 100644 Assets/Scripts/Components/UI/MatchSettingSlider.cs create mode 100644 Assets/Scripts/Components/UI/MatchSettingSlider.cs.meta diff --git a/Assets/Scripts/Components/UI/MatchSettingSlider.cs b/Assets/Scripts/Components/UI/MatchSettingSlider.cs new file mode 100644 index 0000000..60b5cf2 --- /dev/null +++ b/Assets/Scripts/Components/UI/MatchSettingSlider.cs @@ -0,0 +1,100 @@ +using System.Globalization; +using TMPro; +using UnityEngine; +using UnityEngine.UI; + +/// Binds a UI Slider (and optional value label) to one host-side match rule persisted via +/// GameSettings (Settings.json). The slider's range / whole-numbers are authored in the inspector; +/// this only reads/writes the value. The machine that starts the match applies the stored values +/// over the MatchManager's inspector defaults (MatchManager.ApplyHostMatchSettings). +public class MatchSettingSlider : MonoBehaviour +{ + public enum Setting + { + Mode, + HunterCount, + HideDuration, + SeekDuration, + ResolveDuration, + RadarPingInterval, + RadarPingLifetime, + } + + [Tooltip("Which persisted match setting this slider edits.")] + [SerializeField] private Setting setting; + [SerializeField] private Slider slider; + [Tooltip("Optional TMP label showing the current value (mode name / count / seconds).")] + [SerializeField] private TMP_Text valueLabel; + + void OnEnable() + { + if (!slider) return; + float value = ReadSetting(); + slider.SetValueWithoutNotify(value); + UpdateLabel(value); + slider.onValueChanged.AddListener(OnSliderChanged); + } + + void OnDisable() + { + if (slider) slider.onValueChanged.RemoveListener(OnSliderChanged); + } + + void OnSliderChanged(float value) + { + WriteSetting(value); + UpdateLabel(value); + } + + private float ReadSetting() + { + switch (setting) + { + case Setting.Mode: return GameSettings.MatchModeIndex; + case Setting.HunterCount: return GameSettings.MatchHunterCount; + case Setting.HideDuration: return GameSettings.MatchHideDuration; + case Setting.SeekDuration: return GameSettings.MatchSeekDuration; + case Setting.ResolveDuration: return GameSettings.MatchResolveDuration; + case Setting.RadarPingInterval: return GameSettings.MatchRadarPingInterval; + case Setting.RadarPingLifetime: return GameSettings.MatchRadarPingLifetime; + default: return 0f; + } + } + + private void WriteSetting(float value) + { + switch (setting) + { + case Setting.Mode: GameSettings.MatchModeIndex = Mathf.RoundToInt(value); break; + case Setting.HunterCount: GameSettings.MatchHunterCount = Mathf.RoundToInt(value); break; + case Setting.HideDuration: GameSettings.MatchHideDuration = value; break; + case Setting.SeekDuration: GameSettings.MatchSeekDuration = value; break; + case Setting.ResolveDuration: GameSettings.MatchResolveDuration = value; break; + case Setting.RadarPingInterval: GameSettings.MatchRadarPingInterval = value; break; + case Setting.RadarPingLifetime: GameSettings.MatchRadarPingLifetime = value; break; + } + } + + private void UpdateLabel(float value) + { + if (!valueLabel) return; + switch (setting) + { + case Setting.Mode: + valueLabel.text = ((MatchManager.MatchMode)Mathf.RoundToInt(value)).ToString(); + break; + case Setting.HunterCount: + valueLabel.text = Mathf.RoundToInt(value).ToString(); + break; + case Setting.RadarPingInterval when value <= 0f: + valueLabel.text = "Off"; + break; + case Setting.RadarPingLifetime: + valueLabel.text = $"{value.ToString("0.0", CultureInfo.InvariantCulture)} s"; + break; + default: + valueLabel.text = $"{Mathf.RoundToInt(value)} s"; + break; + } + } +} diff --git a/Assets/Scripts/Components/UI/MatchSettingSlider.cs.meta b/Assets/Scripts/Components/UI/MatchSettingSlider.cs.meta new file mode 100644 index 0000000..0a05687 --- /dev/null +++ b/Assets/Scripts/Components/UI/MatchSettingSlider.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f6702d8b80bc1b44aa4723a64e0e529a \ No newline at end of file diff --git a/Assets/Scripts/Helper/GameSettings.cs b/Assets/Scripts/Helper/GameSettings.cs index a4f0e6c..c73a0cd 100644 --- a/Assets/Scripts/Helper/GameSettings.cs +++ b/Assets/Scripts/Helper/GameSettings.cs @@ -18,6 +18,18 @@ public static class GameSettings public float mouseSensitivity = global::MouseSensitivity.Default; public float masterVolume = global::MasterVolume.Default; public string playerName = ""; + + // Host-side match rules (Esc-menu Match Panel). Ranges are authored on the panel sliders; the + // values are applied server-side in MatchManager.StartMatch (with defensive clamps there). + // Defaults mirror the Main scene's authored MatchManager values, so a fresh Settings.json + // starts matches exactly like before the panel existed. + public int matchMode = 0; // MatchManager.MatchMode index (0 = Classic, 1 = Conversion) + public int matchHunterCount = 1; + public float matchHideDuration = 120f; + public float matchSeekDuration = 300f; + public float matchResolveDuration = 50f; + public float matchRadarPingInterval = 30f; + public float matchRadarPingLifetime = 1f; } private static Data _data; @@ -60,6 +72,55 @@ public static class GameSettings } } + /// MatchManager.MatchMode enum index the host starts matches with (0 = Classic, 1 = Conversion). + public static int MatchModeIndex + { + get => Store.matchMode; + set { if (Store.matchMode != value) { Store.matchMode = value; Save(); } } + } + + /// How many randomly chosen players become Hunters at match start. + public static int MatchHunterCount + { + get => Store.matchHunterCount; + set { if (Store.matchHunterCount != value) { Store.matchHunterCount = value; Save(); } } + } + + /// HIDE phase length in seconds. + public static float MatchHideDuration + { + get => Store.matchHideDuration; + set { if (!Mathf.Approximately(Store.matchHideDuration, value)) { Store.matchHideDuration = value; Save(); } } + } + + /// SEEK phase length in seconds. + public static float MatchSeekDuration + { + get => Store.matchSeekDuration; + set { if (!Mathf.Approximately(Store.matchSeekDuration, value)) { Store.matchSeekDuration = value; Save(); } } + } + + /// RESOLUTION (result screen) phase length in seconds. + public static float MatchResolveDuration + { + get => Store.matchResolveDuration; + set { if (!Mathf.Approximately(Store.matchResolveDuration, value)) { Store.matchResolveDuration = value; Save(); } } + } + + /// Seconds between radar ping sweeps during Seek (0 = pings disabled). + public static float MatchRadarPingInterval + { + get => Store.matchRadarPingInterval; + set { if (!Mathf.Approximately(Store.matchRadarPingInterval, value)) { Store.matchRadarPingInterval = value; Save(); } } + } + + /// Seconds each hider radar blip stays alive before it fades. + public static float MatchRadarPingLifetime + { + get => Store.matchRadarPingLifetime; + set { if (!Mathf.Approximately(Store.matchRadarPingLifetime, value)) { Store.matchRadarPingLifetime = value; Save(); } } + } + private static Data Load() { string path = SavePath(); diff --git a/Assets/Scripts/Network/MatchManager.cs b/Assets/Scripts/Network/MatchManager.cs index 96e0e9e..ca93d89 100644 --- a/Assets/Scripts/Network/MatchManager.cs +++ b/Assets/Scripts/Network/MatchManager.cs @@ -198,6 +198,7 @@ public class MatchManager : MonoBehaviour private float _phaseEndTime; // Time.time when the current sub-phase ends (for the HUD countdown) private float _seekStartTime; // Time.time Seek began locally (anchors the ping-countdown cadence, START-aligned) private MatchMode _localMode = MatchMode.Classic; // mirror of the server's matchMode, carried in the phase message + private float _localPingInterval; // mirror of the server's radarPingInterval, carried in the phase message (seeded from the inspector value in Awake) private const string PreMatchTag = "PreMatch"; // scene props destroyed when seek starts @@ -210,10 +211,11 @@ public class MatchManager : MonoBehaviour /// Seconds left in the current sub-phase for the HUD (clamped to 0; cosmetic). public float LocalPhaseRemaining => Mathf.Max(0f, _phaseEndTime - Time.time); - /// Seconds between radar ping sweeps (0 = pings disabled). Baked identically on every client - /// (persistent MatchManager, no replication - like ), so the HUD can - /// drive the ping countdown locally. - public float RadarPingInterval => radarPingInterval; + /// Seconds between radar ping sweeps (0 = pings disabled). Mirror of the SERVER's value, carried + /// in the phase message - the host overrides its inspector default from the Match Panel settings on match + /// start, so the value can no longer be assumed identical on every install. The HUD drives the ping + /// countdown locally from this mirror. + public float RadarPingInterval => _localPingInterval; /// Seconds until the next radar ping sweep for the HUD (counts down from /// and repeats). 0 outside Seek or when pings are disabled. Computed locally from the local Seek-start + @@ -223,9 +225,9 @@ public class MatchManager : MonoBehaviour { get { - if (_subPhase != MatchSubPhase.Seek || radarPingInterval <= 0f) return 0f; - float intoInterval = (Time.time - _seekStartTime) % radarPingInterval; - return radarPingInterval - intoInterval; + if (_subPhase != MatchSubPhase.Seek || _localPingInterval <= 0f) return 0f; + float intoInterval = (Time.time - _seekStartTime) % _localPingInterval; + return _localPingInterval - intoInterval; } } @@ -256,8 +258,9 @@ public class MatchManager : MonoBehaviour // the other client->server ("please end the match"); the server only honors it from the authority id. private const string MsgAuthorityGranted = "Match_AuthorityGranted"; private const string MsgRequestEndMatch = "Match_RequestEndMatch"; - // Server -> all clients: the sub-phase changed (byte phase + float duration). Client -> server: a hunter - // reports a hit on a hider (ulong target client id); the server validates and marks the hider found. + // Server -> all clients: the sub-phase changed (byte phase + float duration + byte mode + float radar ping + // interval). Client -> server: a hunter reports a hit on a hider (ulong target client id); the server + // validates and marks the hider found. private const string MsgPhase = "Match_Phase"; private const string MsgReportHit = "Match_ReportHit"; // Client -> server, right after the client connects: the sender's persistent per-install id (survives its @@ -346,6 +349,7 @@ public class MatchManager : MonoBehaviour { if (Instance && Instance != this) { Destroy(this); return; } Instance = this; + _localPingInterval = radarPingInterval; // pre-match HUD default; overwritten by every phase message _net = GetComponent(); if (!_net) _net = FindFirstObjectByType(); @@ -481,7 +485,7 @@ public class MatchManager : MonoBehaviour _hasAuthorityClient = false; _isLocalAuthority = false; Phase = MatchPhase.Lobby; - ApplyPhaseLocal(MatchSubPhase.None, 0f, _localMode); + ApplyPhaseLocal(MatchSubPhase.None, 0f, _localMode, _localPingInterval); _pendingHiders.Clear(); _persistentIds.Clear(); // Drop any in-flight loading-barrier state (StopAllCoroutines above already killed the polls). @@ -676,6 +680,8 @@ public class MatchManager : MonoBehaviour var ids = new List(_net.ConnectedClientsIds); if (ids.Count == 0) return; + ApplyHostMatchSettings(); + Phase = MatchPhase.Transitioning; // Roll N distinct random Hunters via partial Fisher-Yates (swap a random not-yet-picked id into @@ -695,6 +701,20 @@ public class MatchManager : MonoBehaviour StartCoroutine(MatchSequence(hunterIds)); } + // Server-only, called once per match start. Pulls the host-authored match rules (Esc-menu Match Panel, + // persisted via GameSettings/Settings.json) over the inspector defaults, so the machine that starts the + // match decides the rules. Defensively clamped - the JSON is hand-editable. + private void ApplyHostMatchSettings() + { + matchMode = GameSettings.MatchModeIndex == (int)MatchMode.Conversion ? MatchMode.Conversion : MatchMode.Classic; + hunterCount = Mathf.Max(0, GameSettings.MatchHunterCount); + hideDuration = Mathf.Max(0f, GameSettings.MatchHideDuration); + seekDuration = Mathf.Max(0f, GameSettings.MatchSeekDuration); + resolveDuration = Mathf.Max(0f, GameSettings.MatchResolveDuration); + radarPingInterval = Mathf.Max(0f, GameSettings.MatchRadarPingInterval); + radarPingLifetime = Mathf.Max(0f, GameSettings.MatchRadarPingLifetime); + } + private IEnumerator MatchSequence(HashSet hunterIds) { _conversionRecords.Clear(); // fresh match inherits no stale statue captures (EndMatch also clears) @@ -1027,17 +1047,18 @@ public class MatchManager : MonoBehaviour private void SetPhaseServer(MatchSubPhase phase, float duration) { - ApplyPhaseLocal(phase, duration, matchMode); + ApplyPhaseLocal(phase, duration, matchMode, radarPingInterval); BroadcastPhase(phase, duration, matchMode, 0, true); } // Shared by the server's SetPhaseServer and a client's OnPhaseMessage handler. - private void ApplyPhaseLocal(MatchSubPhase phase, float duration, MatchMode mode) + private void ApplyPhaseLocal(MatchSubPhase phase, float duration, MatchMode mode, float pingInterval) { bool changed = _subPhase != phase; _subPhase = phase; _phaseEndTime = Time.time + duration; _localMode = mode; + _localPingInterval = pingInterval; // Props staged only for the hide phase are gone the instant seek begins; anchor the local ping-countdown // cadence to the same moment (START-aligned, matching the server's sweep timer). if (changed && phase == MatchSubPhase.Seek) @@ -1064,11 +1085,12 @@ public class MatchManager : MonoBehaviour { var cmm = _net ? _net.CustomMessagingManager : null; if (cmm == null) return; - using (var writer = new FastBufferWriter(sizeof(byte) + sizeof(float) + sizeof(byte), Allocator.Temp)) + using (var writer = new FastBufferWriter(sizeof(byte) + sizeof(float) + sizeof(byte) + sizeof(float), Allocator.Temp)) { writer.WriteValueSafe((byte)phase); writer.WriteValueSafe(duration); writer.WriteValueSafe((byte)mode); + writer.WriteValueSafe(radarPingInterval); if (toAll) { _phaseRecipients.Clear(); @@ -1090,7 +1112,8 @@ public class MatchManager : MonoBehaviour reader.ReadValueSafe(out byte phase); reader.ReadValueSafe(out float duration); reader.ReadValueSafe(out byte mode); - ApplyPhaseLocal((MatchSubPhase)phase, duration, (MatchMode)mode); + reader.ReadValueSafe(out float pingInterval); + ApplyPhaseLocal((MatchSubPhase)phase, duration, (MatchMode)mode, pingInterval); } // Server-only. One radar sweep during Seek: registers every still-un-found hider as a blip on each HUNTER's