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