Add the conversion match mode
Introduce a second match mode alongside Classic. In Conversion mode hiders are invisible to each other (and to hunters during Hide), and a hunter hit converts the hider into a full hunter in place instead of sending them to the spectator freecam. The mode is replicated to all clients through the existing phase named-message byte; visibility is computed locally per client per mode. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
1f46e6258d
commit
f485d1676b
2 files changed
+113
-28
No files matched your search
@@ -71,6 +71,18 @@ public class MatchManager : MonoBehaviour
|
|||||||
/// and the match HUD countdown.</summary>
|
/// and the match HUD countdown.</summary>
|
||||||
public enum MatchSubPhase { None, Hide, Seek, Resolution }
|
public enum MatchSubPhase { None, Hide, Seek, Resolution }
|
||||||
|
|
||||||
|
/// <summary>How the running match behaves. Chosen server-side (<see cref="matchMode"/>) 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:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><b>Classic</b> - hiders hidden from hunters during Hide only; a hit marks the hider FOUND
|
||||||
|
/// (body hidden for all, owner drops to free-cam spectating).</item>
|
||||||
|
/// <item><b>Conversion</b> - 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.</item>
|
||||||
|
/// </list></summary>
|
||||||
|
public enum MatchMode { Classic, Conversion }
|
||||||
|
|
||||||
public static MatchManager Instance { get; private set; }
|
public static MatchManager Instance { get; private set; }
|
||||||
|
|
||||||
[Header("Scenes (must be in Build Settings)")]
|
[Header("Scenes (must be in Build Settings)")]
|
||||||
@@ -103,6 +115,12 @@ public class MatchManager : MonoBehaviour
|
|||||||
"fan-out is applied on top (NetworkPlayerSetup.PlaceOnSpawnRing).")]
|
"fan-out is applied on top (NetworkPlayerSetup.PlaceOnSpawnRing).")]
|
||||||
[SerializeField] private Vector3 matchSpawnCenter = new Vector3(0f, 1f, 0f);
|
[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)")]
|
[Header("Match phases (seconds)")]
|
||||||
[Tooltip("HIDE phase length. Hunters move freely but cannot see the hiders (hider meshes are hidden on " +
|
[Tooltip("HIDE phase length. Hunters move freely but cannot see the hiders (hider meshes are hidden on " +
|
||||||
"hunter clients); hiders scatter and hide.")]
|
"hunter clients); hiders scatter and hide.")]
|
||||||
@@ -142,11 +160,14 @@ public class MatchManager : MonoBehaviour
|
|||||||
private MatchSubPhase _subPhase = MatchSubPhase.None;
|
private MatchSubPhase _subPhase = MatchSubPhase.None;
|
||||||
private float _phaseEndTime; // Time.time when the current sub-phase ends (for the HUD countdown)
|
private float _phaseEndTime; // Time.time when the current sub-phase ends (for the HUD countdown)
|
||||||
private float _phaseDuration;
|
private float _phaseDuration;
|
||||||
|
private MatchMode _localMode = MatchMode.Classic; // mirror of the server's matchMode, carried in the phase message
|
||||||
|
|
||||||
/// <summary>True while a match sub-phase (Hide/Seek/Resolution) is running on this machine.</summary>
|
/// <summary>True while a match sub-phase (Hide/Seek/Resolution) is running on this machine.</summary>
|
||||||
public bool MatchActive => _subPhase != MatchSubPhase.None;
|
public bool MatchActive => _subPhase != MatchSubPhase.None;
|
||||||
/// <summary>This machine's current sub-phase (mirror of the server's broadcast).</summary>
|
/// <summary>This machine's current sub-phase (mirror of the server's broadcast).</summary>
|
||||||
public MatchSubPhase LocalSubPhase => _subPhase;
|
public MatchSubPhase LocalSubPhase => _subPhase;
|
||||||
|
/// <summary>The running match's mode (mirror of the server's choice). Drives per-client hider visibility.</summary>
|
||||||
|
public MatchMode LocalMatchMode => _localMode;
|
||||||
/// <summary>Seconds left in the current sub-phase for the HUD (clamped to 0; cosmetic).</summary>
|
/// <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);
|
public float LocalPhaseRemaining => Mathf.Max(0f, _phaseEndTime - Time.time);
|
||||||
/// <summary>Total length of the current sub-phase (for a HUD progress bar, if wanted).</summary>
|
/// <summary>Total length of the current sub-phase (for a HUD progress bar, if wanted).</summary>
|
||||||
@@ -316,7 +337,7 @@ public class MatchManager : MonoBehaviour
|
|||||||
_hasAuthorityClient = false;
|
_hasAuthorityClient = false;
|
||||||
_isLocalAuthority = false;
|
_isLocalAuthority = false;
|
||||||
Phase = MatchPhase.Lobby;
|
Phase = MatchPhase.Lobby;
|
||||||
ApplyPhaseLocal(MatchSubPhase.None, 0f);
|
ApplyPhaseLocal(MatchSubPhase.None, 0f, _localMode);
|
||||||
_pendingHiders.Clear();
|
_pendingHiders.Clear();
|
||||||
if (_net)
|
if (_net)
|
||||||
{
|
{
|
||||||
@@ -389,8 +410,8 @@ public class MatchManager : MonoBehaviour
|
|||||||
if (Phase == MatchPhase.InMatch)
|
if (Phase == MatchPhase.InMatch)
|
||||||
{
|
{
|
||||||
SpawnFor(clientId, spectatorPrefab, matchSpawnCenter);
|
SpawnFor(clientId, spectatorPrefab, matchSpawnCenter);
|
||||||
// Sync the joiner's HUD/visibility to the running sub-phase (remaining time as the duration).
|
// Sync the joiner's HUD/visibility to the running sub-phase + mode (remaining time as the duration).
|
||||||
BroadcastPhase(_subPhase, LocalPhaseRemaining, clientId, false);
|
BroadcastPhase(_subPhase, LocalPhaseRemaining, matchMode, clientId, false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
SpawnFor(clientId, _playerPrefab, lobbySpawnCenter);
|
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.
|
// 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)
|
private void SetPhaseServer(MatchSubPhase phase, float duration)
|
||||||
{
|
{
|
||||||
ApplyPhaseLocal(phase, duration);
|
ApplyPhaseLocal(phase, duration, matchMode);
|
||||||
BroadcastPhase(phase, duration, 0, true);
|
BroadcastPhase(phase, duration, matchMode, 0, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sets this machine's sub-phase mirror + countdown and notifies followers (HUD / visibility). Shared by
|
// Sets this machine's sub-phase mirror + countdown + mode and notifies followers (HUD / visibility).
|
||||||
// the server's SetPhaseServer and a client's OnPhaseMessage handler.
|
// Shared by the server's SetPhaseServer and a client's OnPhaseMessage handler.
|
||||||
private void ApplyPhaseLocal(MatchSubPhase phase, float duration)
|
private void ApplyPhaseLocal(MatchSubPhase phase, float duration, MatchMode mode)
|
||||||
{
|
{
|
||||||
bool changed = _subPhase != phase;
|
bool changed = _subPhase != phase;
|
||||||
_subPhase = phase;
|
_subPhase = phase;
|
||||||
_phaseDuration = duration;
|
_phaseDuration = duration;
|
||||||
_phaseEndTime = Time.time + duration;
|
_phaseEndTime = Time.time + duration;
|
||||||
|
_localMode = mode;
|
||||||
if (changed) SubPhaseChanged?.Invoke(phase);
|
if (changed) SubPhaseChanged?.Invoke(phase);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sends a sub-phase (byte) + its remaining duration (float) to one client, or to all clients except the
|
// Sends a sub-phase (byte) + its remaining duration (float) + the match mode (byte) to one client, or to
|
||||||
// server itself (which applied it directly in SetPhaseServer, so it must not double-apply via loopback).
|
// all clients except the server itself (which applied it directly in SetPhaseServer, so it must not
|
||||||
private void BroadcastPhase(MatchSubPhase phase, float duration, ulong targetClient, bool toAll)
|
// double-apply via loopback).
|
||||||
|
private void BroadcastPhase(MatchSubPhase phase, float duration, MatchMode mode, ulong targetClient, bool toAll)
|
||||||
{
|
{
|
||||||
var cmm = _net != null ? _net.CustomMessagingManager : null;
|
var cmm = _net != null ? _net.CustomMessagingManager : null;
|
||||||
if (cmm == null) return;
|
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((byte)phase);
|
||||||
writer.WriteValueSafe(duration);
|
writer.WriteValueSafe(duration);
|
||||||
|
writer.WriteValueSafe((byte)mode);
|
||||||
if (toAll)
|
if (toAll)
|
||||||
{
|
{
|
||||||
// Every client except the server itself (the host already applied it directly in
|
// 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 byte phase);
|
||||||
reader.ReadValueSafe(out float duration);
|
reader.ReadValueSafe(out float duration);
|
||||||
ApplyPhaseLocal((MatchSubPhase)phase, duration);
|
reader.ReadValueSafe(out byte mode);
|
||||||
|
ApplyPhaseLocal((MatchSubPhase)phase, duration, (MatchMode)mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Client-side: the local hunter's shoot raycast hit the hider owned by <paramref name="targetClientId"/>.
|
/// <summary>Client-side: the local hunter's shoot raycast hit the hider owned by <paramref name="targetClientId"/>.
|
||||||
@@ -619,17 +644,45 @@ public class MatchManager : MonoBehaviour
|
|||||||
MarkHiderFound(targetClientId);
|
MarkHiderFound(targetClientId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server-only. Flags the hider's avatar found (replicated by its NetworkPlayerSetup) and drops it from
|
// Server-only. Resolves a hit on a hider for the active mode, and drops it from the pending set; the seek
|
||||||
// the pending set; the seek loop ends the phase early once that empties.
|
// 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)
|
private void MarkHiderFound(ulong hiderClientId)
|
||||||
{
|
{
|
||||||
_pendingHiders.Remove(hiderClientId);
|
_pendingHiders.Remove(hiderClientId);
|
||||||
if (_net.ConnectedClients.TryGetValue(hiderClientId, out var c) && c.PlayerObject)
|
if (_net.ConnectedClients.TryGetValue(hiderClientId, out var c) && c.PlayerObject)
|
||||||
{
|
{
|
||||||
var setup = c.PlayerObject.GetComponent<NetworkPlayerSetup>();
|
if (matchMode == MatchMode.Conversion)
|
||||||
if (setup) setup.ServerMarkFound();
|
ConvertToHunter(hiderClientId, c.PlayerObject);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var setup = c.PlayerObject.GetComponent<NetworkPlayerSetup>();
|
||||||
|
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<NetworkObject>().SpawnAsPlayerObject(clientId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// True if the client's current avatar is a Hunter role variant (used to gate shooting server-side).
|
// True if the client's current avatar is a Hunter role variant (used to gate shooting server-side).
|
||||||
|
|||||||
@@ -86,7 +86,12 @@ public class NetworkPlayerSetup : NetworkBehaviour
|
|||||||
|
|
||||||
if (IsOwner)
|
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<GameModeManager>(FindObjectsInactive.Include);
|
var manager = FindFirstObjectByType<GameModeManager>(FindObjectsInactive.Include);
|
||||||
if (manager)
|
if (manager)
|
||||||
@@ -168,9 +173,10 @@ public class NetworkPlayerSetup : NetworkBehaviour
|
|||||||
private void Update() => UpdateVisibility();
|
private void Update() => UpdateVisibility();
|
||||||
|
|
||||||
// Per-client visibility: each machine decides whether to render THIS avatar's body from the LOCAL
|
// 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
|
// viewer's perspective (hunter bodies are always visible; only hider bodies are ever hidden). A found
|
||||||
// clients only (the spec - hunters can't see hiders yet). The local owner's own body is left alone unless
|
// hider (Classic) is hidden for everyone. Otherwise it depends on the match MODE - see ComputeVisible.
|
||||||
// found, so this never fights the FirstPersonController's own-body self-hide.
|
// 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()
|
private void UpdateVisibility()
|
||||||
{
|
{
|
||||||
bool found = _isFound.Value;
|
bool found = _isFound.Value;
|
||||||
@@ -187,20 +193,46 @@ public class NetworkPlayerSetup : NetworkBehaviour
|
|||||||
_visibilityApplied = true;
|
_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)
|
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;
|
var match = MatchManager.Instance;
|
||||||
if (match && match.MatchActive
|
if (!match || !match.MatchActive) return true; // lobby / no match -> everything visible
|
||||||
&& match.LocalSubPhase == MatchManager.MatchSubPhase.Hide
|
if (role != PlayerRole.Hider) return true; // hunters are always visible
|
||||||
&& role == PlayerRole.Hider
|
|
||||||
&& LocalViewerIsHunter())
|
|
||||||
return false;
|
|
||||||
|
|
||||||
|
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;
|
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).
|
// True if THIS machine's local player is a Hunter role variant (so it must not see hiders during hide).
|
||||||
private static bool LocalViewerIsHunter()
|
private static bool LocalViewerIsHunter()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in new issue
Block a user