diff --git a/Assets/Scripts/MatchManager.cs b/Assets/Scripts/MatchManager.cs index b130ed5..033ebd2 100644 --- a/Assets/Scripts/MatchManager.cs +++ b/Assets/Scripts/MatchManager.cs @@ -71,6 +71,18 @@ public class MatchManager : MonoBehaviour /// and the match HUD countdown. public enum MatchSubPhase { None, Hide, Seek, Resolution } + /// How the running match behaves. Chosen server-side () and replicated + /// to every client alongside the sub-phase (so visibility is right from the first Hide frame and late + /// joiners sync it). Differs only in hider visibility and what a hit does: + /// + /// Classic - hiders hidden from hunters during Hide only; a hit marks the hider FOUND + /// (body hidden for all, owner drops to free-cam spectating). + /// Conversion - hiders are hidden from EACH OTHER too (Hide and Seek); during Seek only + /// hunters see hiders; a hit CONVERTS the hider into a Hunter (respawned as the Hunter variant in place), + /// so it keeps playing on the hunters' side. Resolution reveals everyone in both modes. + /// + public enum MatchMode { Classic, Conversion } + public static MatchManager Instance { get; private set; } [Header("Scenes (must be in Build Settings)")] @@ -103,6 +115,12 @@ public class MatchManager : MonoBehaviour "fan-out is applied on top (NetworkPlayerSetup.PlaceOnSpawnRing).")] [SerializeField] private Vector3 matchSpawnCenter = new Vector3(0f, 1f, 0f); + [Header("Match mode")] + [Tooltip("Classic = hiders are visible to each other, a hit marks them FOUND (free-cam spectating). " + + "Conversion = hiders are hidden from each other too (only hunters see them during Seek), and a " + + "hit CONVERTS the hider into a Hunter that keeps playing. Replicated to clients on match start.")] + [SerializeField] private MatchMode matchMode = MatchMode.Classic; + [Header("Match phases (seconds)")] [Tooltip("HIDE phase length. Hunters move freely but cannot see the hiders (hider meshes are hidden on " + "hunter clients); hiders scatter and hide.")] @@ -142,11 +160,14 @@ public class MatchManager : MonoBehaviour private MatchSubPhase _subPhase = MatchSubPhase.None; private float _phaseEndTime; // Time.time when the current sub-phase ends (for the HUD countdown) private float _phaseDuration; + private MatchMode _localMode = MatchMode.Classic; // mirror of the server's matchMode, carried in the phase message /// True while a match sub-phase (Hide/Seek/Resolution) is running on this machine. public bool MatchActive => _subPhase != MatchSubPhase.None; /// This machine's current sub-phase (mirror of the server's broadcast). public MatchSubPhase LocalSubPhase => _subPhase; + /// The running match's mode (mirror of the server's choice). Drives per-client hider visibility. + public MatchMode LocalMatchMode => _localMode; /// Seconds left in the current sub-phase for the HUD (clamped to 0; cosmetic). public float LocalPhaseRemaining => Mathf.Max(0f, _phaseEndTime - Time.time); /// Total length of the current sub-phase (for a HUD progress bar, if wanted). @@ -316,7 +337,7 @@ public class MatchManager : MonoBehaviour _hasAuthorityClient = false; _isLocalAuthority = false; Phase = MatchPhase.Lobby; - ApplyPhaseLocal(MatchSubPhase.None, 0f); + ApplyPhaseLocal(MatchSubPhase.None, 0f, _localMode); _pendingHiders.Clear(); if (_net) { @@ -389,8 +410,8 @@ public class MatchManager : MonoBehaviour if (Phase == MatchPhase.InMatch) { SpawnFor(clientId, spectatorPrefab, matchSpawnCenter); - // Sync the joiner's HUD/visibility to the running sub-phase (remaining time as the duration). - BroadcastPhase(_subPhase, LocalPhaseRemaining, clientId, false); + // Sync the joiner's HUD/visibility to the running sub-phase + mode (remaining time as the duration). + BroadcastPhase(_subPhase, LocalPhaseRemaining, matchMode, clientId, false); return; } SpawnFor(clientId, _playerPrefab, lobbySpawnCenter); @@ -532,31 +553,34 @@ public class MatchManager : MonoBehaviour // Server-only. Applies the sub-phase to the server's own mirror and broadcasts it to every other client. private void SetPhaseServer(MatchSubPhase phase, float duration) { - ApplyPhaseLocal(phase, duration); - BroadcastPhase(phase, duration, 0, true); + ApplyPhaseLocal(phase, duration, matchMode); + BroadcastPhase(phase, duration, matchMode, 0, true); } - // Sets this machine's sub-phase mirror + countdown and notifies followers (HUD / visibility). Shared by - // the server's SetPhaseServer and a client's OnPhaseMessage handler. - private void ApplyPhaseLocal(MatchSubPhase phase, float duration) + // Sets this machine's sub-phase mirror + countdown + mode and notifies followers (HUD / visibility). + // Shared by the server's SetPhaseServer and a client's OnPhaseMessage handler. + private void ApplyPhaseLocal(MatchSubPhase phase, float duration, MatchMode mode) { bool changed = _subPhase != phase; _subPhase = phase; _phaseDuration = duration; _phaseEndTime = Time.time + duration; + _localMode = mode; if (changed) SubPhaseChanged?.Invoke(phase); } - // Sends a sub-phase (byte) + its remaining duration (float) to one client, or to all clients except the - // server itself (which applied it directly in SetPhaseServer, so it must not double-apply via loopback). - private void BroadcastPhase(MatchSubPhase phase, float duration, ulong targetClient, bool toAll) + // Sends a sub-phase (byte) + its remaining duration (float) + the match mode (byte) to one client, or to + // all clients except the server itself (which applied it directly in SetPhaseServer, so it must not + // double-apply via loopback). + private void BroadcastPhase(MatchSubPhase phase, float duration, MatchMode mode, ulong targetClient, bool toAll) { var cmm = _net != null ? _net.CustomMessagingManager : null; if (cmm == null) return; - using (var writer = new FastBufferWriter(sizeof(byte) + sizeof(float), Allocator.Temp)) + using (var writer = new FastBufferWriter(sizeof(byte) + sizeof(float) + sizeof(byte), Allocator.Temp)) { writer.WriteValueSafe((byte)phase); writer.WriteValueSafe(duration); + writer.WriteValueSafe((byte)mode); if (toAll) { // Every client except the server itself (the host already applied it directly in @@ -579,7 +603,8 @@ public class MatchManager : MonoBehaviour { reader.ReadValueSafe(out byte phase); reader.ReadValueSafe(out float duration); - ApplyPhaseLocal((MatchSubPhase)phase, duration); + reader.ReadValueSafe(out byte mode); + ApplyPhaseLocal((MatchSubPhase)phase, duration, (MatchMode)mode); } /// Client-side: the local hunter's shoot raycast hit the hider owned by . @@ -619,17 +644,45 @@ public class MatchManager : MonoBehaviour MarkHiderFound(targetClientId); } - // Server-only. Flags the hider's avatar found (replicated by its NetworkPlayerSetup) and drops it from - // the pending set; the seek loop ends the phase early once that empties. + // Server-only. Resolves a hit on a hider for the active mode, and drops it from the pending set; the seek + // loop ends the phase early once that empties (no hiders left to find/convert). Classic flags the avatar + // found (replicated by its NetworkPlayerSetup -> free-cam); Conversion respawns it as a Hunter. private void MarkHiderFound(ulong hiderClientId) { _pendingHiders.Remove(hiderClientId); if (_net.ConnectedClients.TryGetValue(hiderClientId, out var c) && c.PlayerObject) { - var setup = c.PlayerObject.GetComponent(); - if (setup) setup.ServerMarkFound(); + if (matchMode == MatchMode.Conversion) + ConvertToHunter(hiderClientId, c.PlayerObject); + else + { + var setup = c.PlayerObject.GetComponent(); + if (setup) setup.ServerMarkFound(); + } } - Debug.Log($"[MatchManager] Hider {hiderClientId} found - {_pendingHiders.Count} hider(s) left.", this); + string verb = matchMode == MatchMode.Conversion ? "converted to hunter" : "found"; + Debug.Log($"[MatchManager] Hider {hiderClientId} {verb} - {_pendingHiders.Count} hider(s) left.", this); + } + + // Server-only (Conversion mode). Despawns the tagged hider's avatar and respawns it as the Hunter prefab + // variant for the same client, AT THE SAME position/rotation - it converts where it was tagged, no teleport. + // The fresh Hunter's owner skips its spawn-placement because the match is mid-Seek (see NetworkPlayerSetup), + // so the server-set position sticks. Same despawn-then-spawn pattern as RespawnAll; the owner's painting + // carries across via the per-machine paint carry. After this the client plays as a full hunter (size, + // sprint, first-person, shooting) with no special-case runtime state. + private void ConvertToHunter(ulong clientId, NetworkObject current) + { + if (!hunterPrefab) + { + Debug.LogError("[MatchManager] Hunter prefab missing - cannot convert hider.", this); + return; + } + Vector3 pos = current.transform.position; + Quaternion rot = current.transform.rotation; + current.Despawn(true); + + var go = Instantiate(hunterPrefab, pos, rot); + go.GetComponent().SpawnAsPlayerObject(clientId); } // True if the client's current avatar is a Hunter role variant (used to gate shooting server-side). diff --git a/Assets/Scripts/NetworkPlayerSetup.cs b/Assets/Scripts/NetworkPlayerSetup.cs index 5267e4a..af3bb6f 100644 --- a/Assets/Scripts/NetworkPlayerSetup.cs +++ b/Assets/Scripts/NetworkPlayerSetup.cs @@ -86,7 +86,12 @@ public class NetworkPlayerSetup : NetworkBehaviour if (IsOwner) { - PlaceAtSpawn(); + // A Conversion-mode tag respawns the hider as this Hunter variant mid-Seek; keep the server-set + // position (it converts where it was tagged) by skipping the spawn-area/ring placement. Match-start + // variants spawn while the sub-phase is still None, so they DO place; end-match lobby avatars spawn + // after the sub-phase is cleared, so they place too. Only an in-place conversion lands here mid-Seek. + if (!SpawnedMidSeek()) + PlaceAtSpawn(); var manager = FindFirstObjectByType(FindObjectsInactive.Include); if (manager) @@ -168,9 +173,10 @@ public class NetworkPlayerSetup : NetworkBehaviour private void Update() => UpdateVisibility(); // Per-client visibility: each machine decides whether to render THIS avatar's body from the LOCAL - // viewer's perspective. A found hider is hidden for everyone; during HIDE, hiders are hidden on hunter - // clients only (the spec - hunters can't see hiders yet). The local owner's own body is left alone unless - // found, so this never fights the FirstPersonController's own-body self-hide. + // viewer's perspective (hunter bodies are always visible; only hider bodies are ever hidden). A found + // hider (Classic) is hidden for everyone. Otherwise it depends on the match MODE - see ComputeVisible. + // The local owner's own body is left alone unless found, so this never fights the FirstPersonController's + // own-body self-hide. private void UpdateVisibility() { bool found = _isFound.Value; @@ -187,20 +193,46 @@ public class NetworkPlayerSetup : NetworkBehaviour _visibilityApplied = true; } + // Decides whether THIS avatar's body is rendered for the local viewer. Hunters and non-match avatars are + // always visible; a Classic-found hider is always hidden; Resolution reveals everyone. The rest is the + // hider-visibility matrix per mode: + // Classic - Hide: hidden from hunters only (hiders still see each other); Seek: visible to all. + // Conversion - Hide: hidden from EVERYONE (hiders don't see each other either); Seek: only hunters see + // hiders (so they can shoot them; hiders stay blind to each other). private bool ComputeVisible(bool found) { - if (found) return false; + if (found) return false; // Classic: a found hider is out of play, hidden for all var match = MatchManager.Instance; - if (match && match.MatchActive - && match.LocalSubPhase == MatchManager.MatchSubPhase.Hide - && role == PlayerRole.Hider - && LocalViewerIsHunter()) - return false; + if (!match || !match.MatchActive) return true; // lobby / no match -> everything visible + if (role != PlayerRole.Hider) return true; // hunters are always visible + var phase = match.LocalSubPhase; + if (phase == MatchManager.MatchSubPhase.Resolution) return true; // all revealed at the end + + if (match.LocalMatchMode == MatchManager.MatchMode.Conversion) + { + // Hiders are hidden from each other the whole match; only hunters get to see them, and only + // during Seek (so the hide phase truly hides them from everyone but themselves). + if (phase == MatchManager.MatchSubPhase.Hide) return false; + if (phase == MatchManager.MatchSubPhase.Seek) return LocalViewerIsHunter(); + return true; + } + + // Classic: hiders are hidden from hunters during Hide only; visible to everyone otherwise. + if (phase == MatchManager.MatchSubPhase.Hide && LocalViewerIsHunter()) return false; return true; } + // True if this avatar is spawning during the SEEK sub-phase - i.e. a Conversion-mode tag respawning a + // hider as the Hunter variant in place. Match-start and end-match respawns never happen during Seek + // (start spawns before Hide, end spawns after the phase is cleared), so this uniquely flags a conversion. + private static bool SpawnedMidSeek() + { + var match = MatchManager.Instance; + return match && match.LocalSubPhase == MatchManager.MatchSubPhase.Seek; + } + // True if THIS machine's local player is a Hunter role variant (so it must not see hiders during hide). private static bool LocalViewerIsHunter() {