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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Fable 5 committed 2026-07-08 22:04:54 +02:00
1 parent 1468921ca2
commit 270c42d85b
4 files changed
+200 -14

No files matched your search

@@ -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;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f6702d8b80bc1b44aa4723a64e0e529a
+61
View File
@@ -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
}
}
/// <summary>MatchManager.MatchMode enum index the host starts matches with (0 = Classic, 1 = Conversion).</summary>
public static int MatchModeIndex
{
get => Store.matchMode;
set { if (Store.matchMode != value) { Store.matchMode = value; Save(); } }
}
/// <summary>How many randomly chosen players become Hunters at match start.</summary>
public static int MatchHunterCount
{
get => Store.matchHunterCount;
set { if (Store.matchHunterCount != value) { Store.matchHunterCount = value; Save(); } }
}
/// <summary>HIDE phase length in seconds.</summary>
public static float MatchHideDuration
{
get => Store.matchHideDuration;
set { if (!Mathf.Approximately(Store.matchHideDuration, value)) { Store.matchHideDuration = value; Save(); } }
}
/// <summary>SEEK phase length in seconds.</summary>
public static float MatchSeekDuration
{
get => Store.matchSeekDuration;
set { if (!Mathf.Approximately(Store.matchSeekDuration, value)) { Store.matchSeekDuration = value; Save(); } }
}
/// <summary>RESOLUTION (result screen) phase length in seconds.</summary>
public static float MatchResolveDuration
{
get => Store.matchResolveDuration;
set { if (!Mathf.Approximately(Store.matchResolveDuration, value)) { Store.matchResolveDuration = value; Save(); } }
}
/// <summary>Seconds between radar ping sweeps during Seek (0 = pings disabled).</summary>
public static float MatchRadarPingInterval
{
get => Store.matchRadarPingInterval;
set { if (!Mathf.Approximately(Store.matchRadarPingInterval, value)) { Store.matchRadarPingInterval = value; Save(); } }
}
/// <summary>Seconds each hider radar blip stays alive before it fades.</summary>
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();
+37 -14
View File
@@ -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
/// <summary>Seconds left in the current sub-phase for the HUD (clamped to 0; cosmetic).</summary>
public float LocalPhaseRemaining => Mathf.Max(0f, _phaseEndTime - Time.time);
/// <summary>Seconds between radar ping sweeps (0 = pings disabled). Baked identically on every client
/// (persistent MatchManager, no replication - like <see cref="EmbeddedRevealPercent"/>), so the HUD can
/// drive the ping countdown locally.</summary>
public float RadarPingInterval => radarPingInterval;
/// <summary>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.</summary>
public float RadarPingInterval => _localPingInterval;
/// <summary>Seconds until the next radar ping sweep for the HUD (counts down from <see cref="RadarPingInterval"/>
/// 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<NetworkManager>();
if (!_net) _net = FindFirstObjectByType<NetworkManager>();
@@ -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<ulong>(_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<ulong> 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