Reorganize scripts into subfolders by responsibility

Move scripts under Assets/Scripts/ into Components, GameMode, Helper,
Network, Enum, Struct and ScriptableObject folders. Pure file moves
(plus folder .meta files); no code changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-07-02 15:02:15 +02:00
1 parent 382bfb36df
commit e23fae0c50
126 files changed
+112

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 910a44a115a79bd45887e82eefe2d0f9
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,80 @@
using Unity.Netcode;
using UnityEngine;
#if ENABLE_INPUT_SYSTEM
using UnityEngine.InputSystem;
#endif
/// <summary>
/// Dev-only connection harness for local multiplayer testing across the junction-mirror clones: one
/// instance clicks Host and the others click Client (loopback 127.0.0.1 via the configured
/// UnityTransport). Everyone spawns as the neutral Player prefab into the Lobby; roles (Hunter/Hider)
/// are rolled and applied by <see cref="MatchManager"/> when the match starts, not here. Hotkeys:
/// H = Host, C = Client, J = Server, K = Shutdown. Purely a testing scaffold - replace with real
/// lobby/menu UI later. Attach to the same GameObject as the NetworkManager.
/// </summary>
[RequireComponent(typeof(NetworkManager))]
[DisallowMultipleComponent]
public class NetworkBootstrap : MonoBehaviour
{
[Tooltip("Draw the on-screen Host/Client panel (top-left).")]
public bool showGui = true;
[Tooltip("Allow the H/C/J/K keyboard shortcuts.")]
public bool enableHotkeys = true;
private NetworkManager _net;
private void Awake()
{
if (!Application.isEditor)
{
Destroy(this);
return;
}
_net = GetComponent<NetworkManager>();
}
private void Update()
{
if (!enableHotkeys) return;
if (TypingGuard.IsTyping) return; // don't fire H/C/J/K while typing in a text field
#if ENABLE_INPUT_SYSTEM
var kb = Keyboard.current;
if (kb == null || _net == null) return;
if (!_net.IsListening)
{
if (kb.hKey.wasPressedThisFrame) _net.StartHost();
else if (kb.cKey.wasPressedThisFrame) _net.StartClient();
else if (kb.jKey.wasPressedThisFrame) _net.StartServer();
}
else if (kb.kKey.wasPressedThisFrame)
{
_net.Shutdown();
}
#endif
}
private void OnGUI()
{
if (!showGui || _net == null) return;
GUILayout.BeginArea(new Rect(10, 10, 220, 160), GUI.skin.box);
if (!_net.IsListening)
{
GUILayout.Label("Netcode (dev)");
if (GUILayout.Button("Host (H)")) _net.StartHost();
if (GUILayout.Button("Client (C)")) _net.StartClient();
if (GUILayout.Button("Server (J)")) _net.StartServer();
}
else
{
string role = _net.IsHost ? "Host" : _net.IsServer ? "Server" : "Client";
GUILayout.Label($"Role: {role}");
GUILayout.Label($"ClientId: {_net.LocalClientId}");
GUILayout.Label($"Clients: {(_net.IsServer ? _net.ConnectedClients.Count : (_net.IsConnectedClient ? 1 : 0))}");
if (GUILayout.Button("Shutdown (K)")) _net.Shutdown();
}
GUILayout.EndArea();
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 519095f0598dfdf4ebe457561f77771c
+859
View File
@@ -0,0 +1,859 @@
using System.Collections;
using System.Collections.Generic;
using Unity.Collections;
using Unity.Netcode;
using UnityEngine;
using UnityEngine.SceneManagement;
/// <summary>
/// Server-authoritative match flow. Lives on the persistent NetworkManager object in the boot scene
/// (Main); the gameplay scenes (Lobby, Test Map) are loaded ADDITIVELY through NGO's scene manager so
/// Main (cameras/HUD/managers) is never torn down.
///
/// Two design pillars make this model work:
/// 1. Clients sync ADDITIVELY (<see cref="NetworkSceneManager.SetClientSynchronizationMode"/>). The NGO
/// default (Single) would unload the connecting client's own Main and crash the player-object scene
/// migration; additive keeps the client's Main and layers the server's scenes on top. Must be set on
/// the server before any client connects (auto-propagated to clients).
/// 2. Players are spawned by THIS manager, not NGO auto-spawn - that would spawn into whatever scene is
/// active on connect (for the host: floor-less Main, while the additive Lobby load is in flight).
/// <see cref="NetworkConfig.PlayerPrefab"/> is nulled and avatars are spawned manually once the
/// gameplay scene is loaded. Main holds zero network objects.
///
/// Match-control AUTHORITY (may start/end the match): the host; on a DEDICATED server the first client
/// to connect (told via named message, validated server-side on use, reassigned if it leaves).
/// Mid-match joiners spawn as empty free-cam SPECTATOR avatars until the match ends.
///
/// Not a NetworkBehaviour by design: every state change rides existing NGO replication (additive scene
/// sync + player-object spawn/despawn), so there is no in-scene NetworkObject hash matching or custom
/// RPC to get wrong. The phase here is server-local guard state; clients infer their mode set from the
/// spawned avatar (<see cref="NetworkPlayerSetup.ScopedModes"/>), not from a replicated phase, so there
/// is no spawn-vs-phase timing race.
/// </summary>
[DisallowMultipleComponent]
public class MatchManager : MonoBehaviour
{
public enum MatchPhase { Lobby, Transitioning, InMatch }
/// <summary>The in-match sub-phase, replicated to every client over a named message (not via NGO
/// NetworkVariables - this is a MonoBehaviour by design, see class summary). None = no match running.
/// Hide -> Seek -> Resolution, then back to the lobby. Drives per-client visibility, hunter shooting
/// and the match HUD countdown.</summary>
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; }
[Header("Scenes (must be in Build Settings)")]
[Tooltip("Persistent boot scene that always stays loaded (NetworkManager, cameras, HUD). On a " +
"shutdown/disconnect every other scene is unloaded so this is the only one left & active.")]
[SerializeField] private string mainSceneName = "Main";
[SerializeField] private string lobbySceneName = "Lobby";
[SerializeField] private string matchSceneName = "Test Map";
[Header("Role prefab variants (must be registered network prefabs)")]
[SerializeField] private GameObject hunterPrefab;
[SerializeField] private GameObject hiderPrefab;
[Tooltip("Empty free-cam-only avatar spawned for a client that joins MID-MATCH. No body/collider - the " +
"free-cam rig is a separate scene object. Must be a registered network prefab.")]
[SerializeField] private GameObject spectatorPrefab;
[Header("Roles")]
[Tooltip("How many randomly chosen players become Hunters at match start; everyone else is a Hider. " +
"0 = all Hiders; a value at or above the player count makes everyone a Hunter. Default 1.")]
[Min(0)]
[SerializeField] private int hunterCount = 1;
[Header("Spawn")]
[Tooltip("World position lobby avatars are spawned at (raised a touch above the floor so gravity " +
"settles them). Per-client ring fan-out is applied on top (NetworkPlayerSetup.PlaceOnSpawnRing).")]
[SerializeField] private Vector3 lobbySpawnCenter = new Vector3(0f, 1f, 0f);
[Tooltip("World position the respawned match avatars are placed at on the match map; per-client ring " +
"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.")]
[Min(0f)]
[SerializeField] private float hideDuration = 30f;
[Tooltip("SEEK phase length. Hunters can now see the hiders and shoot them. Ends early the moment every " +
"hider has been found.")]
[Min(0f)]
[SerializeField] private float seekDuration = 120f;
[Tooltip("RESOLUTION phase length. Nothing happens here yet; after it elapses everyone returns to the lobby.")]
[Min(0f)]
[SerializeField] private float resolveDuration = 8f;
[Header("Hunter shooting")]
[Tooltip("Max distance (metres) of the hunter's camera-centre shoot raycast.")]
[Min(0f)]
[SerializeField] private float shootRange = 100f;
[Tooltip("Minimum seconds between hunter shots (left-click cooldown).")]
[Min(0f)]
[SerializeField] private float shootCooldown = 0.4f;
[Header("Concealment reveal")]
[Tooltip("A hider whose ConcealmentMeter EmbeddedPercent reaches this value (while a match is running) is " +
"revealed through walls with the material's X-ray glow, so nobody can hide by burying itself deep " +
"inside geometry. 0..100; 100 effectively disables it (never reached). Read owner-side per avatar.")]
[Range(0f, 100f)]
[SerializeField] private float embeddedRevealPercent = 80f;
/// <summary>Range of the hunter's shoot raycast (read by <see cref="FirstPersonMode"/>).</summary>
public float ShootRange => shootRange;
/// <summary>Hunter shoot cooldown in seconds (read by <see cref="FirstPersonMode"/>).</summary>
public float ShootCooldown => shootCooldown;
/// <summary>EmbeddedPercent [0..100] a hider must reach for the through-walls X-ray reveal to switch on
/// (only while a match is running). Read by each owner's <see cref="NetworkPlayerSetup"/> against its own
/// concealment meter; baked identically on every client, so it needs no replication.</summary>
public float EmbeddedRevealPercent => embeddedRevealPercent;
/// <summary>Server-local phase guard. Not replicated - see class summary.</summary>
public MatchPhase Phase { get; private set; } = MatchPhase.Lobby;
// Replicated sub-phase mirror (set by the server's broadcast; the server sets its own copy directly).
// The countdown is cosmetic/local; the authoritative transition is server-driven, so drift self-corrects.
private MatchSubPhase _subPhase = MatchSubPhase.None;
private float _phaseEndTime; // Time.time when the current sub-phase ends (for the HUD countdown)
private MatchMode _localMode = MatchMode.Classic; // mirror of the server's matchMode, carried in the phase message
private const string PreMatchTag = "PreMatch"; // scene props destroyed when seek starts
/// <summary>True while a match sub-phase (Hide/Seek/Resolution) is running on this machine.</summary>
public bool MatchActive => _subPhase != MatchSubPhase.None;
/// <summary>This machine's current sub-phase (mirror of the server's broadcast).</summary>
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>
public float LocalPhaseRemaining => Mathf.Max(0f, _phaseEndTime - Time.time);
private NetworkManager _net;
private GameObject _playerPrefab; // captured from NetworkConfig.PlayerPrefab; auto-spawn is then disabled so we spawn manually into a loaded scene
private bool _hooked;
private bool _sceneReady; // server-only: a gameplay scene (lobby/map) is loaded & active, so it is safe to spawn avatars
private bool _cleanedUp; // guards the shutdown scene-cleanup so a host (server+client stop) doesn't run it twice
// Own generator, seeded from real entropy: UnityEngine.Random's engine-wide state sits at the same
// point every fresh session (nothing consumed before match start), which made the "random" hunter
// land on the same client every time.
private readonly System.Random _roleRng = new System.Random(System.Guid.NewGuid().GetHashCode());
// The LOCAL owner's painting, parked across an avatar respawn (scene switch) by its despawning
// PlayerPaintSync and consumed once by the next owner avatar - this manager is persistent, so it
// bridges the despawn->spawn gap. Cleared on teardown so a fresh session never inherits it.
private byte[] _carriedPaint;
// Match-control authority. Server-local: which client may start/end the match. Host -> itself;
// dedicated server -> the first client to connect.
private bool _hasAuthorityClient;
private ulong _authorityClientId;
// Client-side mirror of the grant message: this machine may ask the server to end the match.
private bool _isLocalAuthority;
// Named messages (CustomMessagingManager). One bridges server->authority-client ("you have authority"),
// 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.
private const string MsgPhase = "Match_Phase";
private const string MsgReportHit = "Match_ReportHit";
// Server-only sub-phase machine. _pendingHiders holds the hiders not yet found; seek ends the instant it
// empties. _phaseRoutine is the running Hide->Seek->Resolution coroutine (stopped if the match is ended
// early via the authority's Back-to-Lobby).
private readonly HashSet<ulong> _pendingHiders = new HashSet<ulong>();
private Coroutine _phaseRoutine;
private readonly List<ulong> _phaseRecipients = new List<ulong>(); // reused scratch for the phase broadcast
private void Awake()
{
if (Instance && Instance != this) { Destroy(this); return; }
Instance = this;
_net = GetComponent<NetworkManager>();
if (!_net) _net = FindFirstObjectByType<NetworkManager>();
// Disable NGO auto-spawn (decided purely by PlayerPrefab != null); keep the reference and spawn
// manually once a floored scene exists. Runtime-only mutation - no asset diff.
if (_net != null)
{
_playerPrefab = _net.NetworkConfig.PlayerPrefab;
_net.NetworkConfig.PlayerPrefab = null;
}
// Placeholder on-screen match HUD, added at runtime so it needs no scene/prefab authoring.
if (!GetComponent<MatchHud>()) gameObject.AddComponent<MatchHud>();
}
private void OnEnable()
{
if (!_net) _net = FindFirstObjectByType<NetworkManager>();
if (!_net) return;
_net.OnServerStarted += OnServerStarted;
_net.OnClientStarted += OnClientStarted;
_net.OnServerStopped += OnNetStopped;
_net.OnClientStopped += OnNetStopped;
}
private void OnDisable()
{
if (_net)
{
_net.OnServerStarted -= OnServerStarted;
_net.OnClientStarted -= OnClientStarted;
_net.OnServerStopped -= OnNetStopped;
_net.OnClientStopped -= OnNetStopped;
UnhookSessionCallbacks();
}
if (Instance == this) Instance = null;
}
// Unhooks the per-session callbacks and named-message handlers (shared by OnDisable and OnNetStopped).
private void UnhookSessionCallbacks()
{
_net.OnClientConnectedCallback -= OnClientConnected;
_net.OnClientDisconnectCallback -= OnClientDisconnected;
if (_net.SceneManager != null) _net.SceneManager.OnSceneEvent -= OnSceneEvent;
if (_net.CustomMessagingManager != null)
{
_net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgAuthorityGranted);
_net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgRequestEndMatch);
_net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgPhase);
_net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgReportHit);
}
}
private void OnServerStarted()
{
_cleanedUp = false;
HookSceneManager();
// Keep the connecting client's already-loaded scenes (its Main) and layer ours on top.
// Must run before any client synchronizes; auto-propagates to clients. See class summary.
if (_net.SceneManager != null)
_net.SceneManager.SetClientSynchronizationMode(LoadSceneMode.Additive);
Phase = MatchPhase.Lobby;
_sceneReady = false;
// Host -> authority right away; dedicated server -> the first client to connect claims it.
_hasAuthorityClient = false;
if (_net.IsHost) { _authorityClientId = _net.LocalClientId; _hasAuthorityClient = true; }
if (_net.CustomMessagingManager != null)
{
_net.CustomMessagingManager.RegisterNamedMessageHandler(MsgRequestEndMatch, OnRequestEndMatchMessage);
_net.CustomMessagingManager.RegisterNamedMessageHandler(MsgReportHit, OnReportHitMessage);
}
_subPhase = MatchSubPhase.None;
_pendingHiders.Clear();
// Spawn late joiners on connect (server-side callback). Re-hook defensively in case of re-host.
_net.OnClientConnectedCallback -= OnClientConnected;
_net.OnClientConnectedCallback += OnClientConnected;
_net.OnClientDisconnectCallback -= OnClientDisconnected;
_net.OnClientDisconnectCallback += OnClientDisconnected;
StartCoroutine(LoadLobbyRoutine());
}
private void OnClientStarted()
{
_cleanedUp = false;
_isLocalAuthority = false;
HookSceneManager();
// Registered at client start so the handlers exist before the server sends.
if (_net.CustomMessagingManager != null)
{
_net.CustomMessagingManager.RegisterNamedMessageHandler(MsgAuthorityGranted, OnAuthorityGrantedMessage);
_net.CustomMessagingManager.RegisterNamedMessageHandler(MsgPhase, OnPhaseMessage);
}
}
// Fired on shutdown / disconnect for both the server and the client (a host gets both, hence the
// _cleanedUp guard). Aborts any in-flight scene op and tears the session's scenes back down so only
// Main is left loaded & active - NGO's own Dispose does NOT unload additively-synced scenes.
private void OnNetStopped(bool _)
{
if (_cleanedUp) return;
_cleanedUp = true;
StopAllCoroutines();
_phaseRoutine = null;
_hooked = false;
_sceneReady = false;
_hasAuthorityClient = false;
_isLocalAuthority = false;
Phase = MatchPhase.Lobby;
ApplyPhaseLocal(MatchSubPhase.None, 0f, _localMode);
_pendingHiders.Clear();
if (_net) UnhookSessionCallbacks();
ReturnToMainOnly();
}
private void HookSceneManager()
{
if (_hooked || _net == null || _net.SceneManager == null) return;
_net.SceneManager.OnSceneEvent += OnSceneEvent;
_hooked = true;
}
// Keep the freshly loaded gameplay scene the ACTIVE scene on every machine (lighting / skybox /
// instantiation context) - NGO additive loads don't promote the active scene on their own.
private void OnSceneEvent(SceneEvent e)
{
if (e.SceneEventType != SceneEventType.LoadComplete) return;
if (e.SceneName != lobbySceneName && e.SceneName != matchSceneName) return;
if (e.Scene.IsValid() && e.Scene.isLoaded) SceneManager.SetActiveScene(e.Scene);
}
private IEnumerator LoadLobbyRoutine()
{
yield return SceneOp(() => _net.SceneManager.LoadScene(lobbySceneName, LoadSceneMode.Additive),
SceneEventType.LoadEventCompleted, lobbySceneName);
// Scene is up - safe to spawn the already-connected peers (host + anyone who joined mid-load).
_sceneReady = true;
foreach (ulong id in new List<ulong>(_net.ConnectedClientsIds)) SpawnFor(id, _playerPrefab, lobbySpawnCenter);
}
// Server-side: a peer finished connecting. If a gameplay scene is already up, spawn it now; otherwise
// the load/respawn routine covers it (the PlayerObject guard in SpawnFor prevents a double spawn).
private void OnClientConnected(ulong clientId)
{
if (_net == null || !_net.IsServer) return;
// Dedicated-server case: the first client to connect becomes the match authority (a host already
// claimed it at server start). Told via named message so it reads Esc locally.
if (!_hasAuthorityClient)
{
_authorityClientId = clientId;
_hasAuthorityClient = true;
SendAuthorityGrant(clientId);
Debug.Log($"[MatchManager] Client {clientId} is now the match authority.", this);
}
// During a Lobby<->Test Map swap the active scene briefly falls back to Main (no floor); defer
// and let the respawn loop spawn this joiner once the target scene is loaded.
if (!_sceneReady || Phase == MatchPhase.Transitioning) return;
// Mid-match joiner: empty SPECTATOR avatar (free-cam only); the end-of-match respawn loop turns
// it into a neutral lobby Player with everyone else.
if (Phase == MatchPhase.InMatch)
{
SpawnFor(clientId, spectatorPrefab, matchSpawnCenter);
// 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);
}
// Server-side: if the match authority leaves, hand authority to another connected client so a
// dedicated-server match stays controllable.
private void OnClientDisconnected(ulong clientId)
{
if (_net == null || !_net.IsServer) return;
// A hider that leaves mid-match can never be found - drop it so "all hiders found" can still trigger.
_pendingHiders.Remove(clientId);
if (!_hasAuthorityClient || clientId != _authorityClientId) return;
if (_net.IsHost && clientId == _net.LocalClientId) return; // host shutting down; teardown handles it
_hasAuthorityClient = false;
foreach (ulong id in _net.ConnectedClientsIds)
{
if (id == clientId) continue; // the leaving client may still be listed momentarily
_authorityClientId = id;
_hasAuthorityClient = true;
SendAuthorityGrant(id);
Debug.Log($"[MatchManager] Match authority reassigned to client {id}.", this);
break;
}
}
// Spawns the given prefab as a client's player object into the currently active gameplay scene. No-op
// if the client already owns a player object (double-spawn guard) or has since disconnected.
private void SpawnFor(ulong clientId, GameObject prefab, Vector3 at)
{
if (!prefab)
{
Debug.LogError("[MatchManager] Spawn prefab missing - cannot spawn.", this);
return;
}
if (!_net.ConnectedClients.TryGetValue(clientId, out var client)) return;
if (client.PlayerObject) return;
var go = Instantiate(prefab, at, Quaternion.identity);
go.GetComponent<NetworkObject>().SpawnAsPlayerObject(clientId);
}
/// <summary>Server-only. Starts the match: roll roles, swap Lobby -> Test Map, respawn as variants.
/// Safe to call repeatedly (only acts once, while still in the lobby phase).</summary>
public void StartMatch()
{
if (_net == null || !_net.IsServer) return;
if (Phase != MatchPhase.Lobby) return; // double-start / not-in-lobby guard
if (!hunterPrefab || !hiderPrefab)
{
Debug.LogError("[MatchManager] Role prefabs not assigned - cannot start match.", this);
return;
}
var ids = new List<ulong>(_net.ConnectedClientsIds);
if (ids.Count == 0) return;
Phase = MatchPhase.Transitioning;
// Roll N distinct random Hunters via partial Fisher-Yates (swap a random not-yet-picked id into
// each leading slot); hunterCount 0 -> all Hiders, >= player count -> all Hunters.
int wanted = Mathf.Clamp(hunterCount, 0, ids.Count);
var hunterIds = new HashSet<ulong>();
for (int i = 0; i < wanted; i++)
{
int j = i + _roleRng.Next(ids.Count - i);
(ids[i], ids[j]) = (ids[j], ids[i]);
hunterIds.Add(ids[i]);
}
Debug.Log($"[MatchManager] Starting match. {hunterIds.Count} hunter(s), " +
$"{ids.Count - hunterIds.Count} hider(s), {ids.Count} player(s).", this);
StartCoroutine(MatchSequence(hunterIds));
}
private IEnumerator MatchSequence(HashSet<ulong> hunterIds)
{
yield return SwapScenes(lobbySceneName, matchSceneName);
RespawnAll(id => hunterIds.Contains(id) ? hunterPrefab : hiderPrefab, matchSpawnCenter);
_pendingHiders.Clear();
foreach (ulong id in _net.ConnectedClientsIds)
if (!hunterIds.Contains(id)) _pendingHiders.Add(id);
Phase = MatchPhase.InMatch;
Debug.Log("[MatchManager] Match started.", this);
// Drive Hide -> Seek -> Resolution -> lobby. Held in a field so an early Back-to-Lobby can stop it.
_phaseRoutine = StartCoroutine(RunMatchPhases());
}
// Server-only. Runs the three sub-phases back to back, then returns everyone to the lobby. Hide and
// Resolution are fixed-length; Seek bails the instant the last hider is found (or its timer runs out).
private IEnumerator RunMatchPhases()
{
SetPhaseServer(MatchSubPhase.Hide, hideDuration);
yield return WaitPhase(hideDuration, null);
SetPhaseServer(MatchSubPhase.Seek, seekDuration);
yield return WaitPhase(seekDuration, () => _pendingHiders.Count == 0);
SetPhaseServer(MatchSubPhase.Resolution, resolveDuration);
yield return WaitPhase(resolveDuration, null);
_phaseRoutine = null;
EndMatch();
}
// Waits up to 'seconds' of server real time, returning early the moment 'until' (if supplied) is true.
private IEnumerator WaitPhase(float seconds, System.Func<bool> until)
{
float t = seconds;
while (t > 0f)
{
if (until != null && until()) yield break;
t -= Time.deltaTime;
yield return null;
}
}
// 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, matchMode);
BroadcastPhase(phase, duration, matchMode, 0, true);
}
// Sets this machine's sub-phase mirror + countdown + mode (followers poll LocalSubPhase).
// 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;
_phaseEndTime = Time.time + duration;
_localMode = mode;
// Props staged only for the hide phase are gone the instant seek begins.
if (changed && phase == MatchSubPhase.Seek) DestroyPreMatchObjects();
}
// Runs on every machine (server directly, clients via the phase message); the tagged props are plain
// scene geometry, not NetworkObjects, so each machine destroys its own local copies.
private static void DestroyPreMatchObjects()
{
var props = GameObject.FindGameObjectsWithTag(PreMatchTag);
for (int i = 0; i < props.Length; i++)
Destroy(props[i]);
}
// Sends the sub-phase + duration + mode 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) + sizeof(byte), Allocator.Temp))
{
writer.WriteValueSafe((byte)phase);
writer.WriteValueSafe(duration);
writer.WriteValueSafe((byte)mode);
if (toAll)
{
_phaseRecipients.Clear();
foreach (ulong id in _net.ConnectedClientsIds)
if (id != _net.LocalClientId) _phaseRecipients.Add(id);
if (_phaseRecipients.Count > 0)
cmm.SendNamedMessage(MsgPhase, _phaseRecipients, writer);
}
else
{
cmm.SendNamedMessage(MsgPhase, targetClient, writer);
}
}
}
// Client handler: the server broadcast a sub-phase change (or sync on join).
private void OnPhaseMessage(ulong senderClientId, FastBufferReader reader)
{
reader.ReadValueSafe(out byte phase);
reader.ReadValueSafe(out float 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"/>.
/// Routes to the server, which validates phase/role/found-state. On a host this IS the server, so it
/// applies directly. No-op outside the seek phase.</summary>
public void ReportHit(ulong targetClientId)
{
if (_net == null || _subPhase != MatchSubPhase.Seek) return;
if (_net.IsServer)
{
if (_pendingHiders.Contains(targetClientId) && IsHunter(_net.LocalClientId))
MarkHiderFound(targetClientId);
return;
}
var cmm = _net.CustomMessagingManager;
if (cmm == null) return;
using (var writer = new FastBufferWriter(sizeof(ulong), Allocator.Temp))
{
writer.WriteValueSafe(targetClientId);
cmm.SendNamedMessage(MsgReportHit, NetworkManager.ServerClientId, writer);
}
}
// Server handler: a hunter client reports its shoot raycast hit a hider. Trusts the client's raycast for
// the hit itself (a co-op hide & seek, not a competitive shooter); the server gates phase, shooter role
// and the target still being an un-found hider.
private void OnReportHitMessage(ulong senderClientId, FastBufferReader reader)
{
reader.ReadValueSafe(out ulong targetClientId);
if (_net == null || !_net.IsServer) return;
if (_subPhase != MatchSubPhase.Seek) return;
if (!_pendingHiders.Contains(targetClientId)) return; // not a live (un-found) hider
if (!IsHunter(senderClientId)) return; // only a hunter may shoot
MarkHiderFound(targetClientId);
}
// 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)
{
if (matchMode == MatchMode.Conversion)
ConvertToHunter(hiderClientId, c.PlayerObject);
else
{
var setup = c.PlayerObject.GetComponent<NetworkPlayerSetup>();
if (setup) setup.ServerMarkFound();
}
}
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 and respawns it as the Hunter variant for
// the same client AT THE SAME position/rotation - no teleport. The owner skips its spawn placement
// mid-Seek (see NetworkPlayerSetup) so the server-set position sticks; painting carries across via
// the paint carry. Afterwards the client is a full hunter 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);
// The owner-authoritative transform ignores the server's Instantiate pos - hand the tagged position
// to the owner, BEFORE the spawn, so it rides the replicated _spawnOverride snapshot.
var setup = go.GetComponent<NetworkPlayerSetup>();
if (setup) setup.MarkConversionSpawn(pos);
go.GetComponent<NetworkObject>().SpawnAsPlayerObject(clientId);
}
// True if the client's current avatar is a Hunter role variant (used to gate shooting server-side).
private bool IsHunter(ulong clientId)
{
if (!_net.ConnectedClients.TryGetValue(clientId, out var c) || !c.PlayerObject) return false;
var setup = c.PlayerObject.GetComponent<NetworkPlayerSetup>();
return setup && setup.ScopedModes && setup.Role == PlayerRole.Hunter;
}
/// <summary>Server-only. Ends the running match: swap Test Map -> Lobby and respawn everyone as the
/// neutral lobby Player (including any spectator-held mid-match joiners). Only acts while in a match.</summary>
public void EndMatch()
{
if (_net == null || !_net.IsServer) return;
if (Phase != MatchPhase.InMatch) return; // only endable mid-match
// Stop the sub-phase machine if the match is being ended early (authority's Back-to-Lobby) and clear
// the replicated sub-phase so every client drops its match HUD/visibility before the scene swaps.
if (_phaseRoutine != null) { StopCoroutine(_phaseRoutine); _phaseRoutine = null; }
_pendingHiders.Clear();
SetPhaseServer(MatchSubPhase.None, 0f);
Phase = MatchPhase.Transitioning;
Debug.Log("[MatchManager] Ending match -> returning to lobby.", this);
StartCoroutine(EndMatchSequence());
}
private IEnumerator EndMatchSequence()
{
yield return SwapScenes(matchSceneName, lobbySceneName);
RespawnAll(_ => _playerPrefab, lobbySpawnCenter);
Phase = MatchPhase.Lobby;
Debug.Log("[MatchManager] Back in lobby.", this);
}
// Unloads one gameplay scene (if loaded), then additively loads the next (shared by the match
// start/end sequences).
private IEnumerator SwapScenes(string unloadSceneName, string loadSceneName)
{
var sm = _net.SceneManager;
Scene old = SceneManager.GetSceneByName(unloadSceneName);
if (old.IsValid() && old.isLoaded)
yield return SceneOp(() => sm.UnloadScene(old), SceneEventType.UnloadEventCompleted, unloadSceneName);
yield return SceneOp(() => sm.LoadScene(loadSceneName, LoadSceneMode.Additive),
SceneEventType.LoadEventCompleted, loadSceneName);
}
// Runs an NGO scene op and waits for its server-side completion event. NGO rejects a new op while one
// is in flight, so callers must await this before issuing the next.
private IEnumerator SceneOp(System.Func<SceneEventProgressStatus> op, SceneEventType completed, string sceneName)
{
bool done = false;
void OnEvt(SceneEvent e)
{
if (e.SceneEventType == completed && e.SceneName == sceneName) done = true;
}
_net.SceneManager.OnSceneEvent += OnEvt;
SceneEventProgressStatus status = op();
if (status != SceneEventProgressStatus.Started)
{
Debug.LogWarning($"[MatchManager] Scene op on '{sceneName}' returned {status}.", this);
_net.SceneManager.OnSceneEvent -= OnEvt;
yield break;
}
while (!done) yield return null;
_net.SceneManager.OnSceneEvent -= OnEvt;
}
// Despawns each connected client's current avatar (if any) and spawns the prefab chosen by
// prefabFor at the given position. Used for both lobby->match (role variants) and match->lobby
// (neutral Player); the latter also gives spectator-held joiners their first avatar.
private void RespawnAll(System.Func<ulong, GameObject> prefabFor, Vector3 at)
{
var ids = new List<ulong>(_net.ConnectedClientsIds);
foreach (ulong id in ids)
{
if (!_net.ConnectedClients.TryGetValue(id, out var client)) continue;
GameObject prefab = prefabFor(id);
if (!prefab) continue;
// Despawn the old avatar first so the client has no stale player object when the new one
// claims the slot (avoids NGO's "already has a player object" reassign warning).
NetworkObject old = client.PlayerObject;
if (old) old.Despawn(true);
var go = Instantiate(prefab, at, Quaternion.identity);
go.GetComponent<NetworkObject>().SpawnAsPlayerObject(id);
}
}
// Unloads every gameplay scene so only Main is left loaded & active. NGO is already gone here
// (shutdown), so this uses Unity's SceneManager, not NGO's. Main must be made active BEFORE the
// active gameplay scene is unloaded, otherwise Unity has no valid active scene to fall back to.
private void ReturnToMainOnly()
{
// A real disconnect/shutdown is not a respawn: drop any parked painting so a fresh session
// (re-host / reconnect) never restores a previous match's painting onto its first avatar.
_carriedPaint = null;
Scene main = SceneManager.GetSceneByName(mainSceneName);
if (main.IsValid() && main.isLoaded) SceneManager.SetActiveScene(main);
UnloadLocalIfLoaded(lobbySceneName);
UnloadLocalIfLoaded(matchSceneName);
// Drop the mode cycle back to a player-less state so the persistent orbit input stops re-locking
// the cursor (and any FreeCam/First-Person takeover is undone).
var modes = FindFirstObjectByType<GameModeManager>(FindObjectsInactive.Include);
if (modes) modes.ReleaseToNoPlayer();
}
private void UnloadLocalIfLoaded(string sceneName)
{
Scene s = SceneManager.GetSceneByName(sceneName);
if (s.IsValid() && s.isLoaded && s != SceneManager.GetActiveScene())
SceneManager.UnloadSceneAsync(s);
}
/// <summary>Owner-side: park the local painting so the avatar a scene switch spawns next can restore it.</summary>
public void StoreCarriedPaint(byte[] data) => _carriedPaint = data;
/// <summary>Owner-side: take (and clear) the parked painting for the freshly spawned avatar, or null if none.</summary>
public byte[] TakeCarriedPaint()
{
byte[] d = _carriedPaint;
_carriedPaint = null;
return d;
}
/// <summary>True if the given client currently holds match-control authority (may start and end the match).</summary>
public bool IsMatchAuthority(ulong clientId) => _hasAuthorityClient && clientId == _authorityClientId;
/// <summary>True if THIS machine holds match authority - the host (whose own client id is the authority)
/// or the granted authority client on a dedicated server. Drives the online panel's Disconnect button:
/// on the map the authority's button means "Back to Lobby" (end the match); everyone else disconnects.</summary>
public bool IsLocalMatchAuthority
{
get
{
if (_net == null) return false;
if (_net.IsServer) return IsMatchAuthority(_net.LocalClientId);
return _isLocalAuthority;
}
}
/// <summary>Ends the running match for everyone (back to the Lobby), routed through authority: the
/// host/server ends directly; a remote authority client asks the server. No-op if this machine is not
/// the authority or no match is running.</summary>
public void RequestEndMatch()
{
if (_net == null) return;
if (_net.IsServer) { EndMatch(); return; } // EndMatch itself guards Phase == InMatch
if (_isLocalAuthority) RequestEndMatchFromServer();
}
// Local active-scene kind. Every peer sets its active scene to Lobby/Map as those load (see OnSceneEvent)
// and back to Main on teardown, so a local UI can tell where it is without the server-local Phase.
// Scene.name allocates a fresh string per call and these are polled per frame -> cached per scene handle.
public bool IsLocalSceneLobby => ActiveSceneName == lobbySceneName;
public bool IsLocalSceneMap => ActiveSceneName == matchSceneName;
private int _activeSceneHandle;
private string _activeSceneName;
private string ActiveSceneName
{
get
{
Scene s = SceneManager.GetActiveScene();
if (_activeSceneName == null || s.handle != _activeSceneHandle)
{
_activeSceneHandle = s.handle;
_activeSceneName = s.name;
}
return _activeSceneName;
}
}
// Sends a named message whose payload carries no information (a named message still needs a buffer,
// so a single dummy byte is written).
private void SendEmptyNamedMessage(string messageName, ulong targetClientId)
{
var cmm = _net != null ? _net.CustomMessagingManager : null;
if (cmm == null) return;
using (var writer = new FastBufferWriter(sizeof(byte), Allocator.Temp))
{
writer.WriteByteSafe(1);
cmm.SendNamedMessage(messageName, targetClientId, writer);
}
}
// Server -> client: tell the designated client it is the match authority (so it reads Esc locally).
private void SendAuthorityGrant(ulong clientId) => SendEmptyNamedMessage(MsgAuthorityGranted, clientId);
// Client handler: this machine has been granted match authority.
private void OnAuthorityGrantedMessage(ulong senderClientId, FastBufferReader reader)
{
_isLocalAuthority = true;
Debug.Log("[MatchManager] This client is the match authority: walk into the Start Match trigger to begin, Esc to end.", this);
}
// Client -> server: ask the server to end the match. Only honored from the authority client (and only
// while a match is running - EndMatch guards the phase).
private void RequestEndMatchFromServer() => SendEmptyNamedMessage(MsgRequestEndMatch, NetworkManager.ServerClientId);
// Server handler: an authority client asked to end the match.
private void OnRequestEndMatchMessage(ulong senderClientId, FastBufferReader reader)
{
if (_net == null || !_net.IsServer) return;
if (!IsMatchAuthority(senderClientId)) return;
EndMatch(); // guards Phase == InMatch itself
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c5a1205b169c81541ae07e8f32fd3673
@@ -0,0 +1,488 @@
using System.Collections;
using Unity.Netcode;
using UnityEngine;
/// <summary>
/// Per-avatar network glue on the spawned player. The local owner binds the scene singletons
/// (GameModeManager, cameras) to itself via <see cref="GameModeManager.BindLocalPlayer"/>; remote
/// avatars stop simulating so their transform is driven purely by the NetworkTransform replication.
/// Position-sync milestone: remote avatars still show their mesh/idle pose, just no local control.
/// </summary>
[RequireComponent(typeof(PlayerCharacterController))]
[DisallowMultipleComponent]
public class NetworkPlayerSetup : NetworkBehaviour
{
[Header("Spawn placement")]
[Tooltip("All avatars spawn from the same prefab position, so without an offset they stack and shove " +
"each other on connect. The owner places itself on a ring keyed by its client id (owner-" +
"authoritative NetworkTransform replicates it). Id 0 (host) stays at the origin; later joiners " +
"fan out. Spacing between ring slots, in world units. 0 disables placement.")]
[SerializeField] private float spawnRingRadius = 2.5f;
[Tooltip("Slots per ring before the radius steps outward, so spacing stays roughly constant as more join.")]
[SerializeField] private int spawnRingSlots = 8;
[Tooltip("Role-specific spawn areas, tried FIRST by an owner based on its Role (Hunter uses " +
"hunterSpawnAreaTag, Hider uses hiderSpawnAreaTag). If a loaded scene holds a GameObject with the " +
"matching tag, the owner spawns at a random point inside its collider. If this avatar's role area " +
"is absent it falls back to spawnAreaTag, then to the client-id ring. Empty disables the role lookup.")]
[SerializeField] private string hunterSpawnAreaTag = "HunterSpawnArea";
[SerializeField] private string hiderSpawnAreaTag = "HiderSpawnArea";
[Tooltip("Shared fallback spawn area, used when this avatar's role-specific area (hunter/hiderSpawnAreaTag) " +
"is not present in any loaded scene. If a loaded scene holds a GameObject with this tag, owners " +
"spawn at a RANDOM point inside its collider instead of the client-id ring (random scatter already " +
"prevents the stack-up the ring guards against). Empty disables the lookup. The area collider " +
"should be convex (a convex MeshCollider or a primitive) so the point is constrained to its actual " +
"shape, not just its bounding box; a non-convex collider falls back to the bounding box.")]
[SerializeField] private string spawnAreaTag = "SpawnArea";
[Tooltip("Metres the spawn is lifted above the spawn area's floor (collider bounds bottom) so the " +
"CharacterController doesn't start embedded; gravity then settles it. Mirrors the lobby/match " +
"spawn-centre lift.")]
[SerializeField] private float spawnAreaGroundLift = 1f;
// Tries to land a random sample inside the convex footprint before falling back to the bounds centre.
// The footprint is typically a chunk of its AABB, so a handful of tries hits the inside with near-certainty.
private const int SpawnAreaSampleAttempts = 24;
[Header("Conversion (mode 2)")]
[Tooltip("Seconds a freshly CONVERTED hunter (a hider shot during Seek in Conversion mode) stays hidden " +
"on other clients after it spawns, so nobody sees it pop in or interpolate from the spawn frame. " +
"The shot hider is despawned immediately, so there is a brief gap, then the hunter cleanly appears " +
"in its place. Per-client and cosmetic; the owner is first-person so it is never gated.")]
[Min(0f)]
[SerializeField] private float conversionRevealDelay = 0.2f;
[Header("Role")]
[Tooltip("Which side this avatar plays. Baked per prefab variant (Hider on the base/Hider prefab, " +
"Hunter on the Hunter prefab); GameModeManager reads it on spawn to build this role's mode set.")]
[SerializeField] private PlayerRole role = PlayerRole.Hider;
[Tooltip("False = lobby/neutral avatar (the base Player prefab) gets the FULL mode set; true = match " +
"avatar (Hunter/Hider variant) gets only its role's modes. Baked per prefab variant, so the " +
"mode set follows the spawned avatar with no replicated-phase timing race.")]
[SerializeField] private bool scopedModes = false;
/// <summary>The side this avatar plays (Hunter/Hider), baked into the prefab variant.</summary>
public PlayerRole Role => role;
/// <summary>True on the match role variants (scope the mode set to <see cref="Role"/>); false on the
/// neutral lobby Player prefab (full mode set). Baked per prefab variant.</summary>
public bool ScopedModes => scopedModes;
// Server-authoritative "this hider has been shot/found" (a hit is validated on the server, not by the
// hider's owner). When it flips true the body hides on every client and the owner drops to spectating.
private readonly NetworkVariable<bool> _isFound =
new NetworkVariable<bool>(false, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server);
/// <summary>True once a hunter has found this hider (replicated). Read by the shoot raycast to skip a
/// target that is already out of play.</summary>
public bool IsFound => _isFound.Value;
// Owner-authoritative "this hider is buried too deep" flag: only the OWNER runs the ConcealmentMeter,
// so it compares EmbeddedPercent against the threshold and writes; every client reads it to switch
// the through-walls X-ray glow.
private readonly NetworkVariable<bool> _embeddedReveal =
new NetworkVariable<bool>(false, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
// Conversion-mode in-place respawn position. The owner holds transform authority, so the server's
// Instantiate position is NOT honoured there; the server writes the tagged position here so it rides
// the spawn snapshot and the owner restores it itself. NaN sentinel = no override (normal placement).
private static readonly Vector3 NoSpawnOverride = new Vector3(float.NaN, float.NaN, float.NaN);
private readonly NetworkVariable<Vector3> _spawnOverride =
new NetworkVariable<Vector3>(NoSpawnOverride, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server);
// Server-only handoff from MatchManager.ConvertToHunter, pushed into _spawnOverride during
// OnNetworkSpawn (a pre-spawn write to the NetworkVariable is silently dropped).
private Vector3? _conversionSpawnPos;
/// <summary>Server-only. Call on a freshly-instantiated Conversion hunter BEFORE spawning it so the owner
/// restores the tagged position (the owner-authoritative transform ignores the server's Instantiate pos).</summary>
public void MarkConversionSpawn(Vector3 worldPos) => _conversionSpawnPos = worldPos;
private Renderer[] _bodyRenderers; // skinned body mesh(es), toggled by the per-client visibility pass
private bool _visibilityApplied;
private bool _lastVisible = true;
private bool _renderGate; // conversion: body force-hidden until the fresh hunter has settled into place
private MeshPainter _painter; // owns the per-avatar material instance carrying _XRayColor
private ConcealmentMeter _concealment; // owner-only embedded-percent source (disabled on remotes)
private PlayerCharacterController _controller; // sibling, cached (the freeze re-asserts per frame)
private bool _xrayApplied;
private bool _lastXRay;
private bool _frozenApplied;
private bool _lastFrozen;
public override void OnNetworkSpawn()
{
_bodyRenderers = GetComponentsInChildren<SkinnedMeshRenderer>(true);
_painter = GetComponentInChildren<MeshPainter>(true);
_concealment = GetComponentInChildren<ConcealmentMeter>(true);
_isFound.OnValueChanged += OnFoundChanged;
if (_isFound.Value) ApplyFound(); // defensive: an avatar is never normally spawned already-found
// Push the Conversion position into the replicated override before the spawn snapshot goes out.
if (IsServer && _conversionSpawnPos.HasValue)
_spawnOverride.Value = _conversionSpawnPos.Value;
var controller = _controller = GetComponent<PlayerCharacterController>();
if (IsOwner)
{
// Conversion respawn: restore the server-tagged position (the owner-authoritative transform
// ignores the server's Instantiate pos); NaN override = normal spawn placement.
Vector3 overridePos = _spawnOverride.Value;
if (float.IsNaN(overridePos.x))
PlaceAtSpawn();
else
ApplyOwnerPosition(overridePos);
var manager = FindFirstObjectByType<GameModeManager>(FindObjectsInactive.Include);
if (manager)
manager.BindLocalPlayer(controller);
return;
}
// Remote avatar: NetworkTransform owns the transform here, so disable the local
// simulation (input/movement) and the CharacterController's own depenetration.
DisableLocalSimulation();
// The concealment meter (per-frame bake + voxelization + overlap) only feeds the LOCAL player;
// a remote's concealment is computed on its own owner's machine, so here it is wasted CPU.
if (_concealment) _concealment.enabled = false;
// NOTE: SkinnedColliderBaker deliberately stays ENABLED on remotes - stamp replay rasterizes its
// live per-frame BakedMesh to map 3D brush placements into UV space; freezing the bake would
// mis-map replicated paint onto any non-rest pose.
// Force AlwaysAnimate so the rig keeps evaluating while this body is HIDDEN locally (hide phase /
// off-screen): the authored CullUpdateTransforms stops writing bones when the renderer isn't
// visible, the pose reset then freezes the rig at rest, SkinnedColliderBaker bakes that wrong
// pose, and replicated paint lands on the wrong body part - a desync that persists once shown
// again. Remote replicas only (an owner is never hidden from its own camera).
foreach (var anim in GetComponentsInChildren<Animator>(true))
anim.cullingMode = AnimatorCullingMode.AlwaysAnimate;
// Conversion: keep the fresh in-place hunter hidden on this remote client for a short settle
// window so nobody sees it pop in or interpolate from the spawn frame. Detected via the
// replicated override (already valid here); the owner returned early above, so it is never gated.
if (!float.IsNaN(_spawnOverride.Value.x))
{
_renderGate = true;
SetBodyRenderers(false); // hide immediately, before the first UpdateVisibility runs
StartCoroutine(RevealAfterSettle());
}
}
public override void OnNetworkDespawn()
{
_isFound.OnValueChanged -= OnFoundChanged;
}
/// <summary>Server-only: flag this hider as found by a hunter. Replicates to every client (the body is
/// hidden for all; the owner drops into free-cam-only spectating). Idempotent.</summary>
public void ServerMarkFound()
{
if (!IsServer || _isFound.Value) return;
_isFound.Value = true; // OnValueChanged -> ApplyFound on every machine, including this server/host
}
private void OnFoundChanged(bool previous, bool current)
{
if (current) ApplyFound();
}
// A found hider leaves play: the per-client visibility pass hides its body, its baked collider is dropped
// (so the invisible body neither blocks nor can be hit again), and on the owning client it switches to
// free-cam-only so the player can still watch the rest of the match. Idempotent.
private void ApplyFound()
{
var baker = GetComponentInChildren<SkinnedColliderBaker>(true);
if (baker) baker.enabled = false;
var meshCol = GetComponentInChildren<MeshCollider>(true);
if (meshCol) meshCol.enabled = false;
if (IsOwner)
{
DisableLocalSimulation();
var manager = FindFirstObjectByType<GameModeManager>(FindObjectsInactive.Include);
if (manager) manager.BindSpectator();
}
}
// Stops this avatar simulating locally: movement input and the CharacterController's own depenetration.
private void DisableLocalSimulation()
{
var controller = GetComponent<PlayerCharacterController>();
if (controller) controller.enabled = false;
var cc = GetComponent<CharacterController>();
if (cc) cc.enabled = false;
}
private void Update()
{
UpdateVisibility();
UpdateEmbeddedReveal();
UpdateXRayReveal();
UpdateResolutionFreeze();
}
// Per-client. During Resolution every still-in-play hider is frozen: the pose graph holds the frame
// (PlayerPoseController.Frozen, change-guarded) and the owner's movement input is cut - re-asserted
// every frozen frame so a stray mode toggle can't quietly re-enable movement during the reveal.
// Nothing is restored: EndMatch despawns this avatar.
private void UpdateResolutionFreeze()
{
bool freeze = ComputeResolutionFreeze();
if (freeze && IsOwner && _controller)
_controller.ControlEnabled = false;
if (_frozenApplied && freeze == _lastFrozen) return;
_lastFrozen = freeze;
_frozenApplied = true;
var pose = GetComponent<PlayerPoseController>();
if (pose) pose.Frozen = freeze;
}
// True while THIS avatar is a still-in-play hider during the local resolution phase (the reveal window).
private bool ComputeResolutionFreeze()
{
var match = MatchManager.Instance;
return match && match.MatchActive &&
match.LocalSubPhase == MatchManager.MatchSubPhase.Resolution &&
role == PlayerRole.Hider && !_isFound.Value;
}
// Per-client visibility: each machine decides whether to render THIS avatar's body from the LOCAL
// viewer's perspective (see ComputeVisible). The 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;
if (IsOwner && !found) return;
// While the conversion render gate is up, the body stays hidden no matter what ComputeVisible says.
bool visible = !_renderGate && ComputeVisible(found);
if (_visibilityApplied && visible == _lastVisible) return;
SetBodyRenderers(visible);
}
// Toggles every body renderer and records the applied state for the change-guard above.
private void SetBodyRenderers(bool visible)
{
if (_bodyRenderers != null)
foreach (var r in _bodyRenderers)
if (r) r.enabled = visible;
_lastVisible = visible;
_visibilityApplied = true;
}
// Conversion: drop the render gate after the settle delay so the next visibility pass reveals the hunter
// at its final, replicated position. Per-client and cosmetic (each client reveals on its own clock).
private IEnumerator RevealAfterSettle()
{
yield return new WaitForSeconds(conversionRevealDelay);
_renderGate = false;
}
// 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; // Classic: a found hider is out of play, hidden for all
var match = MatchManager.Instance;
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;
}
// Owner-only (the meter runs owner-side, so EmbeddedPercent is only available here). Publishes the
// buried-too-deep flag; one network write per actual edge (guarded - the variable dirties on assignment).
private void UpdateEmbeddedReveal()
{
if (!IsOwner) return;
bool over = false;
var match = MatchManager.Instance;
if (match && match.MatchActive && role == PlayerRole.Hider && !_isFound.Value &&
_concealment && _concealment.HasResult)
over = _concealment.EmbeddedPercent >= match.EmbeddedRevealPercent;
if (over != _embeddedReveal.Value) _embeddedReveal.Value = over;
}
// Per-client X-ray glow via the material's _XRayColor alpha (through MeshPainter). Two OR-combined
// triggers: the replicated embedded flag and the local resolution phase. Change-guarded so the
// material is only touched on an actual transition.
private void UpdateXRayReveal()
{
if (!_painter) return;
bool reveal = ComputeXRay();
if (_xrayApplied && reveal == _lastXRay) return;
_painter.XRayRevealed = reveal;
_lastXRay = reveal;
_xrayApplied = true;
}
// The X-ray reveal predicate for this avatar on the local machine.
private bool ComputeXRay()
{
// Buried-hider reveal: already gated to hider + running match on the owner before it is published.
if (_embeddedReveal.Value) return true;
// Resolution reveals every remaining (un-found) hider through walls, on every client -
// exactly the still-in-play-hider-during-Resolution predicate the freeze uses.
return ComputeResolutionFreeze();
}
// True if THIS machine's local player is a Hunter role variant (so it must not see hiders during hide).
// Shared with MatchHud. Called per frame per avatar during hide/seek, so the GetComponent result is
// cached per local PlayerObject; a respawn/conversion swaps the object and refreshes the cache.
private static NetworkObject _localViewerPo;
private static bool _localViewerIsHunter;
internal static bool LocalViewerIsHunter()
{
var nm = NetworkManager.Singleton;
if (!nm || nm.LocalClient == null) return false;
var po = nm.LocalClient.PlayerObject;
if (!po) return false;
if (po != _localViewerPo)
{
_localViewerPo = po;
var setup = po.GetComponent<NetworkPlayerSetup>();
_localViewerIsHunter = setup && setup.ScopedModes && setup.Role == PlayerRole.Hunter;
}
return _localViewerIsHunter;
}
/// <summary>
/// Places the freshly-spawned owner: role-specific spawn area first, then the shared area (a random
/// point inside the collider), falling back to the deterministic client-id ring. Owner-authoritative -
/// the NetworkTransform replicates the result.
/// </summary>
private void PlaceAtSpawn()
{
if (TryGetSpawnArea(out Collider area))
PlaceRandomlyInArea(area);
else
PlaceOnSpawnRing();
}
/// <summary>
/// Re-asserts the server-delivered Conversion position on the owner (whose transform is the source of
/// truth), BEFORE PlayerCharacterController's first Update snapshots the kill-plane rescue target -
/// otherwise a hider tagged mid-climb could fall and be rescued to the still-zero default (origin).
/// The CharacterController is toggled around the write (it overrides direct transform writes).
/// </summary>
private void ApplyOwnerPosition(Vector3 worldPos)
{
var cc = GetComponent<CharacterController>();
CharacterTeleport.Move(cc, transform, worldPos);
}
// Resolves the spawn-area collider for this avatar: the Role-specific area (Hunter/Hider) takes priority,
// then the shared spawnAreaTag area; a miss on both leaves it to the caller's client-id ring fallback.
private bool TryGetSpawnArea(out Collider area)
{
string roleTag = role == PlayerRole.Hunter ? hunterSpawnAreaTag : hiderSpawnAreaTag;
return TryFindAreaByTag(roleTag, out area) || TryFindAreaByTag(spawnAreaTag, out area);
}
// Finds a collider tagged 'tag' in any loaded scene, if present. FindWithTag throws when the tag is
// undefined (e.g. stripped from a build), so it is guarded - a missing/undefined tag just means "not here".
private static bool TryFindAreaByTag(string tag, out Collider area)
{
area = null;
if (string.IsNullOrEmpty(tag)) return false;
GameObject go;
try { go = GameObject.FindWithTag(tag); }
catch (UnityException) { return false; }
if (!go) return false;
area = go.GetComponent<Collider>();
return area;
}
/// <summary>
/// Drops the owner at a random point inside the spawn-area collider: X/Z rejection-sampled against
/// the convex shape (honours the real outline, not just the AABB), Y a touch above the floor so
/// gravity settles. The CharacterController is toggled around the move (see ApplyOwnerPosition).
/// </summary>
private void PlaceRandomlyInArea(Collider area)
{
Bounds b = area.bounds;
bool convex = SupportsClosestPoint(area);
Vector3 pick = b.center;
for (int i = 0; i < SpawnAreaSampleAttempts; i++)
{
Vector3 p = new Vector3(Random.Range(b.min.x, b.max.x), b.center.y, Random.Range(b.min.z, b.max.z));
// ClosestPoint returns the input unchanged when it lies inside a convex collider, so an unchanged
// sample is a point within the real (possibly non-box) footprint.
if (!convex || (area.ClosestPoint(p) - p).sqrMagnitude <= 1e-6f)
{
pick = p;
break;
}
}
Vector3 target = new Vector3(pick.x, b.min.y + spawnAreaGroundLift, pick.z);
ApplyOwnerPosition(target);
}
// Collider.ClosestPoint only constrains to the real shape for primitives and CONVEX mesh colliders;
// a non-convex mesh returns the input unchanged (no containment info), so we fall back to its AABB.
private static bool SupportsClosestPoint(Collider c)
{
return !(c is MeshCollider mc) || mc.convex;
}
/// <summary>
/// Offsets the freshly-spawned owner onto a deterministic ring keyed by its client id so avatars
/// don't pile up at the shared prefab spawn point. Id 0 (host) keeps the origin; later joiners take
/// ring slots, stepping the radius out once a ring fills. Only X/Z shift (gravity settles Y).
/// </summary>
private void PlaceOnSpawnRing()
{
if (spawnRingRadius <= 0f || spawnRingSlots < 1) return;
ulong id = OwnerClientId;
if (id == 0) return; // host stays centered
ulong k = id - 1;
int ring = (int)(k / (ulong)spawnRingSlots);
int slot = (int)(k % (ulong)spawnRingSlots);
float radius = spawnRingRadius * (ring + 1);
// Half-slot stagger per ring so outer slots don't line up radially with inner ones.
float angle = (slot / (float)spawnRingSlots + ring * 0.5f / spawnRingSlots) * Mathf.PI * 2f;
Vector3 offset = new Vector3(Mathf.Cos(angle), 0f, Mathf.Sin(angle)) * radius;
ApplyOwnerPosition(transform.position + offset);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: fce6d601a5d475943bc43d2abd75570d
@@ -0,0 +1,241 @@
using System;
using System.Collections.Generic;
using Unity.Netcode;
using Unity.Services.Authentication;
using Unity.Services.Core;
using Unity.Services.Multiplayer;
using UnityEngine;
/// <summary>
/// Online hosting/joining over Unity Gaming Services so players can connect across the internet
/// without port forwarding. Uses the unified Multiplayer Services "Sessions" API, which bundles
/// Relay (NAT-free messaging via Unity's relay servers) + Lobby (the public server list) + anonymous
/// Authentication behind single calls, and configures + starts the NGO transport itself - so we do
/// NOT call <see cref="NetworkManager.StartHost"/>/<c>StartClient</c> here; CreateSessionAsync starts
/// the host and JoinSession* starts the client.
///
/// Terminology: the UGS "session" is the entry in the public server list (network layer). It is a
/// different thing from the in-game "Lobby" scene (the pre-match staging area that <see cref="MatchManager"/>
/// loads after the connection is up). This component does not touch the match flow at all - the usual
/// NetworkManager callbacks fire exactly as they do for loopback hosting, so MatchManager, spawning and
/// authority are unaffected.
///
/// This is the logic layer only: state + async host/refresh/join/disconnect calls, surfaced through the
/// <see cref="Changed"/>/<see cref="SessionsChanged"/> events. The uGUI server browser that drives it lives
/// on its own canvas (<see cref="OnlineSessionBrowserUI"/>); this component carries no UI of its own.
/// Requires the project to be linked to a UGS cloud project (Project Settings > Services) with Relay,
/// Lobby and Authentication (Anonymous) enabled; otherwise sign-in fails with a clear status message.
/// Attach to the same GameObject as the NetworkManager.
/// </summary>
[RequireComponent(typeof(NetworkManager))]
[DisallowMultipleComponent]
public class OnlineSessionManager : MonoBehaviour
{
[Tooltip("Maximum players per hosted session (host included).")]
public int maxPlayers = 8;
private NetworkManager _net;
private ISession _session; // active session (host or client); null when not in one
private List<ISessionInfo> _sessions = new List<ISessionInfo>();
private string _hostName = "";
private string _status = "Idle";
private bool _busy;
private bool _leaving; // suppress the defensive leave while an explicit leave is in flight
/// <summary>Raised when status, busy state, or the connection state changes, so a bound UI can re-render.
/// Async continuations resume on the main thread, so handlers run there.</summary>
public event Action Changed;
/// <summary>Raised after the browsable session list is replaced (a refresh completed).</summary>
public event Action SessionsChanged;
public string Status => _status;
public bool IsBusy => _busy;
public bool IsConnected => _net && _net.IsListening;
public IReadOnlyList<ISessionInfo> Sessions => _sessions;
private void Awake()
{
_net = GetComponent<NetworkManager>();
_hostName = $"{SystemInfo.deviceName} game";
}
private void OnEnable()
{
if (!_net) return;
_net.OnServerStarted += OnNetworkStarted;
_net.OnClientStarted += OnNetworkStarted;
_net.OnServerStopped += OnNetworkStopped;
_net.OnClientStopped += OnNetworkStopped;
}
private void OnDisable()
{
if (!_net) return;
_net.OnServerStarted -= OnNetworkStarted;
_net.OnClientStarted -= OnNetworkStarted;
_net.OnServerStopped -= OnNetworkStopped;
_net.OnClientStopped -= OnNetworkStopped;
}
private void OnNetworkStarted() => Changed?.Invoke();
// The match-end / disconnect / Shutdown paths stop the NetworkManager without telling UGS. Leave the
// session here too so the Relay allocation and the public lobby entry are released.
private void OnNetworkStopped(bool _)
{
var stale = _session;
_session = null;
if (stale != null && !_leaving) LeaveSilently(stale);
Changed?.Invoke();
}
private async void LeaveSilently(ISession session)
{
try { await session.LeaveAsync(); }
catch (Exception e) { Debug.LogWarning($"[OnlineSession] leave-on-stop failed: {e.Message}"); }
}
// ----- Public API (driven by OnlineSessionBrowserUI) -------------------------------------------------
public void Host() => HostAsync();
public void Refresh() => RefreshAsync();
public void Join(string sessionId) => JoinAsync(sessionId);
public void Disconnect() => DisconnectAsync();
// ----- Implementation --------------------------------------------------------------------------------
private void SetStatus(string status)
{
_status = status;
Changed?.Invoke();
}
private void SetBusy(bool busy)
{
_busy = busy;
Changed?.Invoke();
}
private async System.Threading.Tasks.Task EnsureSignedInAsync()
{
if (UnityServices.State != ServicesInitializationState.Initialized)
{
// Anonymous tokens are cached per machine under company/product, NOT per project path - so
// junction-mirror clones (shared ProjectSettings) would otherwise resolve to one UGS player id
// and collide on join (409 LobbyConflict). Namespacing the auth cache by a hash of this install's
// path gives each clone a stable, distinct identity. Harmless in production - separate machines
// already have separate caches; this only matters for same-machine clone testing.
var options = new InitializationOptions();
options.SetProfile(ProfileForThisInstall());
await UnityServices.InitializeAsync(options);
}
if (!AuthenticationService.Instance.IsSignedIn)
await AuthenticationService.Instance.SignInAnonymouslyAsync();
}
// Deterministic, regex-safe (^[a-zA-Z0-9_-]{1,30}$) profile derived from the install path, so the same
// clone is always the same UGS player across restarts - no random per-launch identity, no orphaned ghost
// players/lobbies piling up. SHA1 over dataPath -> "mirror_" + 12 hex chars = 19 chars (fits the limit).
private static string ProfileForThisInstall()
{
using (var sha1 = System.Security.Cryptography.SHA1.Create())
{
var bytes = sha1.ComputeHash(System.Text.Encoding.UTF8.GetBytes(Application.dataPath));
var name = new System.Text.StringBuilder("mirror_", 19);
for (int i = 0; i < 6; i++) name.Append(bytes[i].ToString("x2"));
return name.ToString();
}
}
// Shared busy/sign-in/error skeleton around one session operation; the body carries the
// op-specific statuses and state.
private async System.Threading.Tasks.Task RunSessionOpAsync(string failLabel, Func<System.Threading.Tasks.Task> body)
{
SetBusy(true);
try
{
SetStatus("Signing in…");
await EnsureSignedInAsync();
await body();
}
catch (Exception e)
{
SetStatus($"{failLabel} failed: {e.Message}");
Debug.LogException(e);
}
finally { SetBusy(false); }
}
private async void HostAsync()
{
if (_busy || IsConnected) return;
await RunSessionOpAsync("Host", async () =>
{
SetStatus("Creating session…");
var options = new SessionOptions
{
Name = string.IsNullOrWhiteSpace(_hostName) ? "Chameleon game" : _hostName.Trim(),
MaxPlayers = Mathf.Max(2, maxPlayers)
}.WithRelayNetwork();
_session = await MultiplayerService.Instance.CreateSessionAsync(options);
SetStatus($"Hosting \"{_session.Name}\" (code {_session.Code})");
});
}
private async void RefreshAsync()
{
if (_busy) return;
await RunSessionOpAsync("Refresh", async () =>
{
SetStatus("Querying sessions…");
var results = await MultiplayerService.Instance.QuerySessionsAsync(new QuerySessionsOptions());
_sessions = new List<ISessionInfo>(results.Sessions);
SessionsChanged?.Invoke();
SetStatus($"{_sessions.Count} session(s) found");
});
}
private async void JoinAsync(string sessionId)
{
if (_busy || IsConnected || string.IsNullOrEmpty(sessionId)) return;
await RunSessionOpAsync("Join", async () =>
{
SetStatus("Joining session…");
_session = await MultiplayerService.Instance.JoinSessionByIdAsync(sessionId);
SetStatus($"Joined \"{_session.Name}\"");
});
}
private async void DisconnectAsync()
{
if (_busy) return;
// A UGS session leaves cleanly (LeaveAsync also tears down the NGO connection). A plain loopback
// host/client - if one is ever active without a session - just shuts the transport down.
if (_session != null)
{
SetBusy(true);
_leaving = true;
var session = _session;
_session = null;
try
{
SetStatus("Leaving…");
await session.LeaveAsync();
SetStatus("Left session");
}
catch (Exception e)
{
SetStatus($"Leave failed: {e.Message}");
Debug.LogException(e);
}
finally { _leaving = false; SetBusy(false); }
}
else if (IsConnected)
{
_net.Shutdown();
SetStatus("Disconnected");
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 64babb133b483be49972dd184b31d84b
@@ -0,0 +1,118 @@
using Unity.Netcode;
using UnityEngine;
/// <summary>
/// Replicates the visual body state that the disabled-on-remote controller / pose system would
/// otherwise drive: the owner publishes its selected pose index, resolved locomotion category
/// (idle / walk / run / fall) AND the wall-lean / ground-raise / ceiling mesh offset, so a remote
/// avatar shows the same pose, walk/run/fall animation and leans into walls / raises off the ground
/// like the owner — not just a sliding, upright idle.
///
/// Body position/rotation come from the NetworkTransform; this only carries the small extra state on
/// top. Pose index and locomotion change rarely (a pose cycle, a locomotion transition), so those
/// NetworkVariables send a delta only when the value actually changes. The mesh offset is the visual
/// model's child-local position (<see cref="PlayerCharacterController.MeshOffsetTarget"/>) — a tiny
/// vector the owner eases continuously; the remote eases onto the replicated value so it doesn't
/// tick-step (the body itself is already interpolated by the NetworkTransform).
///
/// Runs after the PlayerPoseController (execution order 100) so the owner reads this frame's resolved
/// state; remote avatars apply via OnValueChanged / per-frame easing, independent of frame order.
/// </summary>
[RequireComponent(typeof(PlayerPoseController))]
[RequireComponent(typeof(PlayerCharacterController))]
[DisallowMultipleComponent]
[DefaultExecutionOrder(200)]
public class PlayerAnimationSync : NetworkBehaviour
{
[Tooltip("How fast a remote avatar eases its mesh lean/raise offset onto the replicated value " +
"(1/second). Higher = tighter follow, lower = smoother. The body itself is interpolated " +
"by the NetworkTransform; this only smooths the small child-local lean offset.")]
[SerializeField] private float meshOffsetFollow = 18f;
private readonly NetworkVariable<int> _poseIndex =
new(0, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
// Locomotion category stored as a byte (PlayerPoseController.Locomotion has only a handful of values).
private readonly NetworkVariable<byte> _locomotion =
new(0, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
// Visual model's child-local lean/raise offset: wall lean + ground raise + ceiling lean, all folded
// by the controller into MeshOffsetTarget.localPosition. Owner-authoritative like the rest. Already in
// the root's local space, so it stays correct under the synced body rotation (wall/ceiling) as-is.
private readonly NetworkVariable<Vector3> _meshOffset =
new(Vector3.zero, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
private PlayerPoseController _pose;
private PlayerCharacterController _controller;
private Transform _offsetTarget;
void Awake()
{
_pose = GetComponent<PlayerPoseController>();
_controller = GetComponent<PlayerCharacterController>();
}
public override void OnNetworkSpawn()
{
// Resolved after all Awakes ran: the controller resolves MeshOffsetTarget in its own Awake.
_offsetTarget = _controller ? _controller.MeshOffsetTarget : null;
if (IsOwner)
return;
// Remote avatar: stop reading local input/physics and drive the pose from replicated state.
_pose.EnableNetworkDrive();
_poseIndex.OnValueChanged += OnPoseIndexChanged;
_locomotion.OnValueChanged += OnLocomotionChanged;
ApplyRemote(); // spawn-time values are already replicated
// Seed the lean offset to the replicated value so a mid-lean late join doesn't pop from base.
if (_offsetTarget) _offsetTarget.localPosition = _meshOffset.Value;
}
public override void OnNetworkDespawn()
{
_poseIndex.OnValueChanged -= OnPoseIndexChanged;
_locomotion.OnValueChanged -= OnLocomotionChanged;
}
void Update()
{
if (IsOwner)
{
if (!_pose)
return;
// Publish the owner's resolved animation state. Assigning the same value is a no-op for
// replication (the NetworkVariable dirties only on an actual change).
int index = _pose.CurrentIndex;
byte loco = (byte)_pose.CurrentLocomotion;
if (_poseIndex.Value != index) _poseIndex.Value = index;
if (_locomotion.Value != loco) _locomotion.Value = loco;
// Mesh lean/raise offset: the controller owns MeshOffsetTarget.localPosition (sole writer),
// so this frame's value is final by execution order 200.
if (_offsetTarget)
{
Vector3 lp = _offsetTarget.localPosition;
if (_meshOffset.Value != lp) _meshOffset.Value = lp;
}
return;
}
// Remote avatar: ease the visual model onto the replicated lean/raise offset. The controller is
// disabled here, so this is the sole writer of MeshOffsetTarget.localPosition on the remote (the
// pose reset already excludes that node). Easing avoids tick-rate stepping on the small offset.
if (_offsetTarget)
{
float k = 1f - Mathf.Exp(-meshOffsetFollow * Time.deltaTime);
_offsetTarget.localPosition = Vector3.Lerp(_offsetTarget.localPosition, _meshOffset.Value, k);
}
}
private void OnPoseIndexChanged(int previous, int current) => ApplyRemote();
private void OnLocomotionChanged(byte previous, byte current) => ApplyRemote();
private void ApplyRemote() =>
_pose.SetNetworkState(_poseIndex.Value, (PlayerPoseController.Locomotion)_locomotion.Value);
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8c52bb7282c319e4f8c1f956d17cbdca
+659
View File
@@ -0,0 +1,659 @@
using System.Collections;
using System.Collections.Generic;
using Unity.Netcode;
using UnityEngine;
/// <summary>
/// Replicates mesh painting across the network as brush-stamp COMMANDS (tens of bytes each), not
/// pixels: the owner batches each frame's committed stamps and forwards them; remote avatars replay
/// them through their own painter onto the identical mesh/UVs - live painting without streaming maps.
///
/// Undo/redo replicates as commands too: every replica keeps the SAME per-stroke history (see
/// MeshPainter.Undo), so the owner forwards per-stroke CHECKPOINT markers and undo/redo RESTORE ids.
/// A peer that lacks the target id (undo past its join point) heals by pulling a fresh snapshot from
/// the OWNER - the only guaranteed-correct source. Stamps are capped per frame with the overflow
/// carried over (never dropped), and markers never overtake the stamps they finalize, so histories
/// stay aligned and the heal path stays rare.
///
/// Late join: on spawn the client pulls a compressed snapshot from the server (chunked, throttled),
/// buffers live events during the transfer, and afterwards replays only those newer than the
/// snapshot's cutoff sequence - nothing lost or double-applied across the join window.
///
/// Respawn carry: a scene switch despawns the avatar, which would otherwise respawn blank. The owner
/// parks a snapshot on the persistent MatchManager; the replacement avatar loads and rebroadcasts it
/// (the manual-load path). The carry-broadcast and the replica's own late-join request can overlap, so
/// transfers are id-tagged and cutoff-ordered - they never mix buffers or regress to older state.
///
/// Emission intensity is NOT baked into the maps (a global material multiplier), so it rides its own
/// owner-authoritative NetworkVariable instead of the stamp/snapshot path.
/// </summary>
[DisallowMultipleComponent]
public class PlayerPaintSync : NetworkBehaviour
{
[Tooltip("Max brush stamps forwarded to the network per frame. Pathological fast strokes (hundreds " +
"of interpolated sub-stamps in one frame) are capped to this to bound the RPC size; the " +
"overflow is CARRIED OVER to the next frame (never dropped), so remotes replay the identical " +
"stamp set and undo/redo history stays aligned. Local painting is always full fidelity.")]
[SerializeField] private int maxStampsPerFrame = 64;
[Tooltip("Log paint-sync events (stamps/checkpoints/restores sent & applied, snapshot transfers) to " +
"the console with a per-peer tag. Handy for diagnosing undo/redo replication; turn off for " +
"quiet play.")]
[SerializeField] private bool logSync = true;
[Tooltip("Snapshot transfer chunk payload (bytes). Kept under the transport MTU so each chunk is a " +
"single packet (no extra fragmentation).")]
[SerializeField] private int snapshotChunkBytes = 1200;
[Tooltip("Snapshot chunks sent per frame. Throttled so the burst stays under the reliable window " +
"(~32 in-flight) while live stamps keep flowing.")]
[SerializeField] private int snapshotChunksPerFrame = 8;
private MeshPainter _painter;
// Single owner-side FIFO of everything to forward, in the exact order it was produced: brush stamps
// interleaved with stroke checkpoints and undo/redo restores. Flushed in order in LateUpdate so a
// checkpoint/restore never overtakes the stamps it refers to, and so the per-frame stamp cap can carry
// overflow to the next frame without ever reordering or dropping a command.
private enum OutKind { Stamp, Checkpoint, Restore }
private struct OutItem
{
public OutKind kind;
public PaintStampNet stamp; // Stamp only
public int id; // Checkpoint / Restore only
}
private readonly List<OutItem> _outgoing = new List<OutItem>();
// Global emission-intensity multiplier - a per-player material value, not part of the painted maps.
// The remote applies it to its painter; RefreshEmissionColor pushes it to the material per frame.
private readonly NetworkVariable<float> _emissionIntensity =
new(1f, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
// Per-player camo knobs on the PlayerCamoLit material (x = HalfLambert, y = its brightness,
// z = specular dampen) - material values like emission intensity, applied once the remote painter
// is ready and on every change. The default mirrors Player.mat's authored camo so the brief
// pre-publish spawn frame shows no flash.
private readonly NetworkVariable<Vector3> _camo =
new(new Vector3(0.5f, 0f, 0.1f), NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
private bool _camoApplyPending; // remote: apply _camo.Value to the material once IsReady / on change
// Painted-height knobs (x = occlusion AO threshold, y = relief normal intensity, z = parallax
// intensity). Same treatment as the camo knobs; the default mirrors the shader defaults.
private readonly NetworkVariable<Vector3> _height =
new(new Vector3(0.2f, 1.4f, 1f), NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
private bool _heightApplyPending; // remote: apply _height.Value to the material once IsReady / on change
// Monotonic message counter on the owner (one per stamp batch / checkpoint / restore); on every peer,
// the highest sequence accounted for (applied to a remote replica / sent by the owner). Used as the
// snapshot's cutoff marker so the join window neither loses nor double-applies events.
private int _seq;
private int _seqHorizon;
// --- Joining client: snapshot reassembly + live-event buffering until the baseline is applied ---
private enum NetKind { Stamps, Checkpoint, Restore }
private struct NetEvent
{
public int seq;
public NetKind kind;
public PaintStampNet[] stamps; // Stamps only
public int id; // Checkpoint / Restore only
}
private bool _awaitingSnapshot; // buffer live events instead of applying them
private byte[] _snapBuffer; // reassembled compressed snapshot
private int _snapCapturedSeq; // events <= this are already baked into the snapshot
private int _snapBaselineId; // checkpoint id the snapshot maps represent (history baseline)
private bool _snapReadyToApply; // last chunk arrived; apply once the painter is ready
private readonly List<NetEvent> _buffered = new List<NetEvent>();
// A scene-switch respawn delivers TWO snapshots at once (the replica's late-join request AND the
// owner's carry broadcast); without isolation they'd share one reassembly buffer and decode a mixed
// blob. Each transfer carries a unique id; only the active one fills the buffer and applies.
private int _activeXferId;
// Highest snapshot cutoff accepted so far: an OLDER transfer is dropped (a late blank late-join must
// not overwrite the newer carry); EQUAL is accepted so the owner-snapshot heal path still works.
private int _acceptedCutoffFloor = int.MinValue;
// Owner-only: painting carried over from the just-despawned avatar (taken from MatchManager on
// spawn), applied in LateUpdate once this fresh painter is ready. Null = nothing to carry.
private byte[] _pendingCarry;
private void Awake() => _painter = GetComponentInChildren<MeshPainter>(true);
public override void OnNetworkSpawn()
{
if (!_painter)
return;
if (IsOwner)
{
_painter.OnStampCommitted += OnStampCommitted;
_painter.OnHistoryCheckpoint += OnLocalCheckpoint;
_painter.OnHistoryRestore += OnLocalRestore;
if (MatchManager.Instance) _pendingCarry = MatchManager.Instance.TakeCarriedPaint();
}
else
{
// Remote replica: never paint from this machine's mouse; paint only from replicated data.
_painter.PaintingEnabled = false;
// Track the owner's emission intensity; seed the current (late-join) value right away.
_emissionIntensity.OnValueChanged += OnEmissionChanged;
_painter.emissionIntensity = _emissionIntensity.Value;
// Deferred to LateUpdate - the camo setters are a no-op before the painter's Start builds
// the material instance.
_camo.OnValueChanged += OnCamoChanged;
_camoApplyPending = true;
// Same deferred apply for the painted-height knobs.
_height.OnValueChanged += OnHeightChanged;
_heightApplyPending = true;
// The host's replica already holds the full paint via the live stream; only a genuine
// late joiner needs the baseline pulled over.
if (!IsServer)
{
_awaitingSnapshot = true;
if (logSync) LogSync("late joiner: requesting baseline snapshot");
RequestSnapshotRpc();
}
}
}
public override void OnNetworkDespawn()
{
if (_painter && IsOwner)
{
// Park the painting for the avatar the scene switch spawns next. Captured here, the latest
// possible point, so last-second strokes are included (a real disconnect clears the copy).
if (MatchManager.Instance && _painter.TryCaptureSnapshot(out byte[] snap))
MatchManager.Instance.StoreCarriedPaint(snap);
_painter.OnStampCommitted -= OnStampCommitted;
_painter.OnHistoryCheckpoint -= OnLocalCheckpoint;
_painter.OnHistoryRestore -= OnLocalRestore;
}
_emissionIntensity.OnValueChanged -= OnEmissionChanged;
_camo.OnValueChanged -= OnCamoChanged;
_height.OnValueChanged -= OnHeightChanged;
}
// Owner history events -> appended to the ordered outgoing FIFO, flushed in LateUpdate.
private void OnLocalCheckpoint(int id) => _outgoing.Add(new OutItem { kind = OutKind.Checkpoint, id = id });
private void OnLocalRestore(int id) => _outgoing.Add(new OutItem { kind = OutKind.Restore, id = id });
// Per-peer log tag: which client this is and its role, so multi-window logs are easy to read.
private string SyncTag
{
get
{
string role = IsOwner ? "owner" : (IsServer ? "host" : "client");
ulong id = NetworkManager ? NetworkManager.LocalClientId : 0;
return $"[PaintSync c{id} {role}]";
}
}
// Call sites are guarded with `if (logSync)` so the interpolated message string is never
// built when logging is off (some sites run per frame while stamps are flushing).
private void LogSync(string msg)
{
if (logSync) Debug.Log($"{SyncTag} {msg}", this);
}
private void OnEmissionChanged(float previous, float current)
{
if (_painter) _painter.emissionIntensity = current;
}
// Apply on the next LateUpdate once the material is ready (the camo setters are a no-op before that,
// and on a fresh spawn the OnValueChanged may arrive before the painter's Start has built the instance).
private void OnCamoChanged(Vector3 previous, Vector3 current) => _camoApplyPending = true;
private void OnHeightChanged(Vector3 previous, Vector3 current) => _heightApplyPending = true;
// ---- Live stamp stream (owner -> server -> remotes) ----
private void OnStampCommitted(PaintStampNet stamp) =>
_outgoing.Add(new OutItem { kind = OutKind.Stamp, stamp = stamp });
private void LateUpdate()
{
// Apply a fully-received snapshot once the painter has finished setup, then flush the events
// that arrived during the transfer. Deferred to here so apply+replay happen atomically in order.
if (_snapReadyToApply && _painter && _painter.IsPaintReady)
{
_snapReadyToApply = false;
_painter.ApplyPaintSnapshot(_snapBuffer, _snapBaselineId);
if (logSync) LogSync($"applied snapshot baseline {_snapBaselineId}; replaying buffered events");
FlushBuffered();
_snapBuffer = null;
_awaitingSnapshot = false;
}
// Remote replica: apply the owner's camo once the material exists / on change (the owner drives
// its own material via HUD + persistence).
if (!IsOwner && _camoApplyPending && _painter && _painter.IsReady)
{
_painter.CamoKnobs = _camo.Value;
_camoApplyPending = false;
}
// Remote replica: apply the owner's painted-height knobs once the material exists / on change.
if (!IsOwner && _heightApplyPending && _painter && _painter.IsReady)
{
_painter.HeightKnobs = _height.Value;
_heightApplyPending = false;
}
if (!IsOwner)
return;
// Restore a carried-over painting once the painter is ready, then propagate it like a manual
// load. One-shot: cleared whether or not it took, so a failed carry never retries every frame.
if (_pendingCarry != null && _painter && _painter.IsPaintReady)
{
byte[] carry = _pendingCarry;
_pendingCarry = null;
if (_painter.LoadPaintFromSnapshot(carry))
{
if (logSync) LogSync($"carried painting onto respawned avatar ({carry.Length} bytes); rebroadcasting to peers");
BroadcastLoadedPaint();
}
}
// Delta-only publish: the NetworkVariable dirties only on an actual change.
if (_painter && _emissionIntensity.Value != _painter.emissionIntensity)
_emissionIntensity.Value = _painter.emissionIntensity;
// Camo/height publish, delta-only and gated on IsReady so the pre-Start 0-read is never
// published (it would briefly flatten remotes' lighting until the real value lands).
if (_painter && _painter.IsReady)
{
Vector3 camo = _painter.CamoKnobs;
if (_camo.Value != camo)
_camo.Value = camo;
Vector3 height = _painter.HeightKnobs;
if (_height.Value != height)
_height.Value = height;
}
FlushOutgoing(unlimited: false);
}
/// <summary>
/// Sends the outgoing FIFO in order: runs of consecutive stamps are batched (capped per frame unless
/// <paramref name="unlimited"/>), checkpoints/restores go out as tiny markers in their exact position.
/// Each item gets its own monotonic sequence. With the per-frame cap reached, the unsent tail (stamps
/// and any markers behind them) stays queued for the next frame - never reordered, never dropped.
/// </summary>
private void FlushOutgoing(bool unlimited)
{
if (_outgoing.Count == 0)
return;
int cap = Mathf.Max(1, maxStampsPerFrame);
int sentStamps = 0;
int consumed = 0;
while (consumed < _outgoing.Count)
{
OutKind kind = _outgoing[consumed].kind;
if (kind == OutKind.Stamp)
{
if (!unlimited && sentStamps >= cap)
break; // per-frame budget spent; carry the remainder (this stamp on) to next frame
int budget = unlimited ? int.MaxValue : (cap - sentStamps);
int runEnd = consumed;
while (runEnd < _outgoing.Count
&& _outgoing[runEnd].kind == OutKind.Stamp
&& (runEnd - consumed) < budget)
runEnd++;
int take = runEnd - consumed;
var batch = new PaintStampNet[take];
for (int k = 0; k < take; k++)
batch[k] = _outgoing[consumed + k].stamp;
int seq = ++_seq;
_seqHorizon = seq;
SubmitStampsServerRpc(seq, batch);
sentStamps += take;
consumed = runEnd;
}
else
{
int id = _outgoing[consumed].id;
int seq = ++_seq;
_seqHorizon = seq;
if (kind == OutKind.Restore)
{
SubmitRestoreServerRpc(seq, id);
if (logSync) LogSync($"sent RESTORE to checkpoint {id} (seq {seq})");
}
else
{
SubmitCheckpointServerRpc(seq, id);
if (logSync) LogSync($"sent checkpoint {id} (seq {seq})");
}
consumed++;
}
}
if (consumed > 0)
_outgoing.RemoveRange(0, consumed);
if (!unlimited && _outgoing.Count > 0)
if (logSync) LogSync($"stamp cap ({cap}) reached; {_outgoing.Count} command(s) carried over to next frame");
}
// ---- Stamp / checkpoint / restore commands (owner -> server -> remotes) ----
[ServerRpc]
private void SubmitStampsServerRpc(int seq, PaintStampNet[] stamps) => ApplyStampsClientRpc(seq, stamps);
[ServerRpc]
private void SubmitCheckpointServerRpc(int seq, int id) => ApplyCheckpointClientRpc(seq, id);
[ServerRpc]
private void SubmitRestoreServerRpc(int seq, int id) => ApplyRestoreClientRpc(seq, id);
/// <summary>
/// Common gate for the replicated commands: the owner already applied them locally, so only remotes
/// (incl. the host's replica) proceed; during a snapshot transfer the event is buffered instead.
/// Returns true when the caller should apply the event live.
/// </summary>
private bool GateRemoteEvent(NetEvent ev)
{
if (IsOwner || !_painter)
return false;
_seqHorizon = ev.seq; // server replica tracks this as the snapshot cutoff source
if (_awaitingSnapshot)
{
_buffered.Add(ev);
return false;
}
return true;
}
[ClientRpc]
private void ApplyStampsClientRpc(int seq, PaintStampNet[] stamps)
{
if (!GateRemoteEvent(new NetEvent { seq = seq, kind = NetKind.Stamps, stamps = stamps }))
return;
for (int i = 0; i < stamps.Length; i++)
_painter.ApplyNetworkStamp(stamps[i]);
}
[ClientRpc]
private void ApplyCheckpointClientRpc(int seq, int id)
{
if (!GateRemoteEvent(new NetEvent { seq = seq, kind = NetKind.Checkpoint, id = id }))
return;
_painter.RemoteCommitCheckpoint(id);
if (logSync) LogSync($"committed checkpoint {id} (seq {seq})");
}
[ClientRpc]
private void ApplyRestoreClientRpc(int seq, int id)
{
if (!GateRemoteEvent(new NetEvent { seq = seq, kind = NetKind.Restore, id = id }))
return;
// Reproduce the undo/redo from this peer's own history; if it doesn't hold that checkpoint (an
// undo past this peer's join point), fetch a fresh snapshot of the resulting state instead.
if (_painter.RemoteRestoreTo(id))
{
if (logSync) LogSync($"reproduced RESTORE to checkpoint {id} (seq {seq})");
}
else
{
if (logSync) LogSync($"cannot reproduce checkpoint {id} (not in local history, seq {seq}) -> owner snapshot fallback");
RequestSnapshotFallback();
}
}
private void FlushBuffered()
{
if (_painter)
{
for (int b = 0; b < _buffered.Count; b++)
{
var ev = _buffered[b];
if (ev.seq <= _snapCapturedSeq) continue; // already inside the snapshot baseline
if (ev.kind == NetKind.Stamps)
{
for (int i = 0; i < ev.stamps.Length; i++)
_painter.ApplyNetworkStamp(ev.stamps[i]);
}
else if (ev.kind == NetKind.Checkpoint)
{
_painter.RemoteCommitCheckpoint(ev.id);
}
else // Restore
{
if (!_painter.RemoteRestoreTo(ev.id))
{
// Can't reproduce -> the fresh snapshot we request next supersedes the rest.
if (logSync) LogSync($"buffered RESTORE to checkpoint {ev.id} not reproducible -> owner snapshot fallback");
_buffered.Clear();
RequestSnapshotFallback();
return;
}
}
}
}
_buffered.Clear();
}
/// <summary>
/// A peer that can't reproduce a restore from its own history asks the OWNER (the only guaranteed
/// correct source of the post-restore state) for a fresh snapshot addressed back to just this peer.
/// Works uniformly for a remote client and for the host's own replica.
/// </summary>
private void RequestSnapshotFallback()
{
_awaitingSnapshot = true;
RequestOwnerSnapshotRpc();
}
[Rpc(SendTo.Owner, InvokePermission = RpcInvokePermission.Everyone)]
private void RequestOwnerSnapshotRpc(RpcParams p = default)
{
// Runs on the owner. Drain any carried-over commands first so the snapshot's cutoff covers them
// (they would otherwise replay on top of a snapshot that already contains them), then capture the
// authoritative current maps and ship them to just the requester.
if (!_painter)
return;
FlushOutgoing(unlimited: true);
if (!_painter.TryCaptureSnapshot(out byte[] data))
return;
ulong requester = p.Receive.SenderClientId;
int baselineId = _painter.CurrentCheckpointId;
if (logSync) LogSync($"serving fallback snapshot to client {requester} (cutoff {_seq}, baseline {baselineId}, {data.Length} bytes)");
StartCoroutine(UploadSnapshot(data, _seq, baselineId, broadcast: false, target: requester));
}
// ---- Late-join snapshot transfer (server -> the one joining client) ----
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)]
private void RequestSnapshotRpc(RpcParams p = default)
{
if (!_painter || !_painter.TryCaptureSnapshot(out byte[] data))
return;
// Cutoff = everything the captured maps already contain (owner: its send counter; otherwise the
// last message this replica applied). The checkpoint id tags the baseline the joiner adopts.
int capturedSeq = IsOwner ? _seq : _seqHorizon;
int baselineId = _painter.CurrentCheckpointId;
if (logSync) LogSync($"serving late-join snapshot to client {p.Receive.SenderClientId} (cutoff {capturedSeq}, baseline {baselineId}, {data.Length} bytes)");
StartCoroutine(SendSnapshot(new[] { p.Receive.SenderClientId }, data, capturedSeq, baselineId));
}
// Server-side monotonic id stamped on each outgoing snapshot transfer, so a replica that receives two
// overlapping transfers (respawn: its late-join + the owner's carry) can tell them apart and isolate them.
private int _xferOut;
private IEnumerator SendSnapshot(ulong[] targets, byte[] data, int capturedSeq, int baselineId)
{
var send = new ClientRpcParams { Send = new ClientRpcSendParams { TargetClientIds = targets } };
int xferId = ++_xferOut;
BeginSnapshotClientRpc(xferId, data.Length, capturedSeq, baselineId, send);
yield return SendChunked(data, (off, chunk) => SnapshotChunkClientRpc(xferId, off, chunk, send));
EndSnapshotClientRpc(xferId, send);
}
/// <summary>Feeds a blob chunk-wise into sendChunk(offset, chunk), throttled to snapshotChunksPerFrame.</summary>
private IEnumerator SendChunked(byte[] data, System.Action<int, byte[]> sendChunk)
{
int sentThisFrame = 0;
for (int off = 0; off < data.Length; off += snapshotChunkBytes)
{
int len = Mathf.Min(snapshotChunkBytes, data.Length - off);
var chunk = new byte[len];
System.Array.Copy(data, off, chunk, 0, len);
sendChunk(off, chunk);
if (++sentThisFrame >= snapshotChunksPerFrame)
{
sentThisFrame = 0;
yield return null; // spread the burst so the reliable window does not overflow
}
}
}
[ClientRpc]
private void BeginSnapshotClientRpc(int xferId, int totalBytes, int capturedSeq, int baselineId, ClientRpcParams p = default)
{
// Drop a transfer that carries older state than one already accepted/applied (a late blank late-join
// racing the carry). Equal cutoff is allowed so the owner-snapshot heal still lands.
if (capturedSeq < _acceptedCutoffFloor)
{
if (logSync) LogSync($"ignoring stale snapshot #{xferId} (cutoff {capturedSeq} < accepted {_acceptedCutoffFloor})");
return;
}
_acceptedCutoffFloor = capturedSeq;
_activeXferId = xferId;
_snapBuffer = new byte[totalBytes];
_snapCapturedSeq = capturedSeq;
_snapBaselineId = baselineId;
_snapReadyToApply = false;
_awaitingSnapshot = true;
if (logSync) LogSync($"receiving snapshot #{xferId} ({totalBytes} bytes, cutoff {capturedSeq}, baseline {baselineId})");
}
[ClientRpc]
private void SnapshotChunkClientRpc(int xferId, int offset, byte[] chunk, ClientRpcParams p = default)
{
if (xferId != _activeXferId || _snapBuffer == null) return; // stale chunk from a superseded transfer
CopyChunkInto(_snapBuffer, offset, chunk);
}
/// <summary>Copies a received chunk into the reassembly buffer, ignoring out-of-bounds offsets.</summary>
private static void CopyChunkInto(byte[] buffer, int offset, byte[] chunk)
{
if (offset >= 0 && offset + chunk.Length <= buffer.Length)
System.Array.Copy(chunk, 0, buffer, offset, chunk.Length);
}
[ClientRpc]
private void EndSnapshotClientRpc(int xferId, ClientRpcParams p = default)
{
if (xferId != _activeXferId) return; // stale End from a superseded transfer; the active one still owns the buffer
// Apply happens in LateUpdate (once the painter is ready) so apply + buffered replay stay ordered.
_snapReadyToApply = _snapBuffer != null;
if (!_snapReadyToApply)
_awaitingSnapshot = false;
}
// ---- Load re-sync (owner -> server -> every other peer) ----
//
// Loading a saved painting replaces the whole map set and clears undo history, so the live stream
// cannot carry it. The owner uploads a snapshot to the server (chunked), which re-broadcasts it to
// every other peer; the baseline id resets each peer's history and the cutoff sequence drops any
// in-flight/buffered events the loaded baseline supersedes.
private byte[] _uploadBuffer; // server-side reassembly of the owner's uploaded snapshot
private int _uploadCutoff;
private int _uploadBaselineId;
private bool _uploadBroadcast; // true = fan out to every peer but the owner (load); false = single target
private ulong _uploadTarget; // the single requester when !_uploadBroadcast (fallback heal)
/// <summary>Owner-only: broadcast the just-loaded painting to all other peers. No-op offline / single-player.</summary>
public void BroadcastLoadedPaint()
{
if (!IsSpawned || !IsOwner || !_painter) return;
if (!_painter.TryCaptureSnapshot(out byte[] data)) return;
// The loaded baseline replaces the whole painting and clears history, so any still-queued local
// stamps/checkpoints from before the load are stale - drop them rather than stream them on top.
_outgoing.Clear();
int cutoff = ++_seq; // every event up to here is now superseded by the loaded baseline
_seqHorizon = cutoff;
int baselineId = _painter.CurrentCheckpointId; // the load's fresh baseline id
if (logSync) LogSync($"broadcasting loaded painting to all peers (cutoff {cutoff}, baseline {baselineId}, {data.Length} bytes)");
StartCoroutine(UploadSnapshot(data, cutoff, baselineId, broadcast: true, target: 0));
}
private IEnumerator UploadSnapshot(byte[] data, int cutoff, int baselineId, bool broadcast, ulong target)
{
BeginUploadServerRpc(data.Length, cutoff, baselineId, broadcast, target);
yield return SendChunked(data, (off, chunk) => UploadChunkServerRpc(off, chunk));
EndUploadServerRpc();
}
[ServerRpc]
private void BeginUploadServerRpc(int totalBytes, int cutoff, int baselineId, bool broadcast, ulong target)
{
_uploadBuffer = totalBytes > 0 ? new byte[totalBytes] : null;
_uploadCutoff = cutoff;
_uploadBaselineId = baselineId;
_uploadBroadcast = broadcast;
_uploadTarget = target;
}
[ServerRpc]
private void UploadChunkServerRpc(int offset, byte[] chunk)
{
if (_uploadBuffer == null) return;
CopyChunkInto(_uploadBuffer, offset, chunk);
}
[ServerRpc]
private void EndUploadServerRpc()
{
if (_uploadBuffer == null) return;
// Load broadcast: every peer except the owner (who already loaded it locally). Fallback heal:
// only the single requester. A non-owner host is included by its client id like any remote.
ulong[] targets = _uploadBroadcast ? OtherClientIds(OwnerClientId) : new[] { _uploadTarget };
if (targets.Length > 0)
StartCoroutine(SendSnapshot(targets, _uploadBuffer, _uploadCutoff, _uploadBaselineId));
_uploadBuffer = null;
}
/// <summary>All connected client ids except the given one (server-side).</summary>
private ulong[] OtherClientIds(ulong except)
{
var ids = NetworkManager.ConnectedClientsIds;
var list = new List<ulong>(ids.Count);
for (int i = 0; i < ids.Count; i++)
if (ids[i] != except) list.Add(ids[i]);
return list.ToArray();
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f1b1a094f669d6b458370f63773462ed
+22
View File
@@ -0,0 +1,22 @@
using Unity.Netcode;
using UnityEngine;
/// <summary>
/// Per-avatar glue for a mid-match SPECTATOR. The spectator object is deliberately empty - no body, no
/// renderer, no collider, no controller - because the free-fly camera is a separate, persistent scene
/// object (in Main) that drives its own rig off Camera.main and needs nothing from this avatar. So the
/// spectator only has to exist as the owner's player object and flip the local mode set to free-cam-only.
/// The owner does that here; remote peers do nothing (there is no body to show or simulate). When the
/// host ends the match, MatchManager respawns this client as a neutral lobby Player.
/// </summary>
[DisallowMultipleComponent]
public class SpectatorSetup : NetworkBehaviour
{
public override void OnNetworkSpawn()
{
if (!IsOwner) return;
var manager = FindFirstObjectByType<GameModeManager>(FindObjectsInactive.Include);
if (manager) manager.BindSpectator();
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: afc5afb485e7fdb4e94dec910509cc97
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 56eb762d74e4dba4db3f16160a6e082c
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+93
View File
@@ -0,0 +1,93 @@
using UnityEngine;
/// <summary>
/// Placeholder on-screen match HUD: the current sub-phase + countdown (top centre) and a hunter crosshair
/// during the seek phase. Drawn with IMGUI (OnGUI) on purpose - it needs no Canvas / font asset authoring,
/// so the match-state logic can be exercised immediately; swap it for a proper uGUI canvas once the look is
/// finalised. Added at runtime by <see cref="MatchManager"/> (AddComponent), so it rides the persistent
/// manager object and reads <see cref="MatchManager.Instance"/> on every machine.
/// </summary>
[DisallowMultipleComponent]
public class MatchHud : MonoBehaviour
{
private GUIStyle _phaseStyle;
private GUIStyle _timeStyle;
private int _lastSecond = -1; // countdown text cache: OnGUI runs 2+ times per frame,
private string _timeText = ""; // so the string is only rebuilt when the displayed second changes
private void OnGUI()
{
var match = MatchManager.Instance;
if (!match || !match.MatchActive) return;
EnsureStyles();
int s = Mathf.CeilToInt(Mathf.Max(0f, match.LocalPhaseRemaining));
if (s != _lastSecond)
{
_lastSecond = s;
_timeText = $"{s / 60:0}:{s % 60:00}";
}
const float w = 360f;
var phaseRect = new Rect((Screen.width - w) * 0.5f, 12f, w, 30f);
var timeRect = new Rect(phaseRect.x, phaseRect.y + 28f, w, 36f);
DrawShadowed(phaseRect, PhaseLabel(match.LocalSubPhase), _phaseStyle);
DrawShadowed(timeRect, _timeText, _timeStyle);
if (match.LocalSubPhase == MatchManager.MatchSubPhase.Seek && NetworkPlayerSetup.LocalViewerIsHunter())
DrawCrosshair();
}
private static string PhaseLabel(MatchManager.MatchSubPhase phase)
{
switch (phase)
{
case MatchManager.MatchSubPhase.Hide: return "Verstecken";
case MatchManager.MatchSubPhase.Seek: return "Suchen";
case MatchManager.MatchSubPhase.Resolution: return "Auflösung";
default: return "";
}
}
private static void DrawCrosshair()
{
float cx = Screen.width * 0.5f;
float cy = Screen.height * 0.5f;
const float r = 8f, t = 2f;
Color prev = GUI.color;
GUI.color = new Color(1f, 1f, 1f, 0.85f);
GUI.DrawTexture(new Rect(cx - r, cy - t * 0.5f, r * 2f, t), Texture2D.whiteTexture);
GUI.DrawTexture(new Rect(cx - t * 0.5f, cy - r, t, r * 2f), Texture2D.whiteTexture);
GUI.color = prev;
}
// One label drawn twice (dark offset behind, then the real colour) so it stays readable on any backdrop.
private static void DrawShadowed(Rect r, string text, GUIStyle style)
{
if (string.IsNullOrEmpty(text)) return;
Color real = style.normal.textColor;
style.normal.textColor = new Color(0f, 0f, 0f, 0.7f);
GUI.Label(new Rect(r.x + 1f, r.y + 1f, r.width, r.height), text, style);
style.normal.textColor = real;
GUI.Label(r, text, style);
}
private void EnsureStyles()
{
if (_phaseStyle == null) _phaseStyle = MakeCenteredBoldLabel(22);
if (_timeStyle == null) _timeStyle = MakeCenteredBoldLabel(28);
}
private static GUIStyle MakeCenteredBoldLabel(int fontSize)
{
var style = new GUIStyle(GUI.skin.label)
{
alignment = TextAnchor.MiddleCenter,
fontSize = fontSize,
fontStyle = FontStyle.Bold,
};
style.normal.textColor = Color.white;
return style;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 130fbae3929f3274a87fa23acc5b45f5
@@ -0,0 +1,314 @@
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
#if ENABLE_INPUT_SYSTEM
using UnityEngine.InputSystem;
#endif
/// <summary>
/// uGUI binding for <see cref="OnlineSessionManager"/>. Drives the server-browser canvas - Host / Refresh /
/// pick a row / Join / Disconnect plus a status line - and mirrors the manager's state through its
/// <see cref="OnlineSessionManager.Changed"/> / <see cref="OnlineSessionManager.SessionsChanged"/> events.
/// Lives on the browser canvas; the session logic stays on the NetworkManager's
/// <see cref="OnlineSessionManager"/>.
///
/// Selection model: Refresh fills the list with clones of <see cref="entryTemplate"/> (each a clickable row
/// showing name + player count). Clicking a row selects it; Join connects to the selected session. While
/// connected, only Disconnect is interactable.
///
/// Overlay behaviour: the panel is always shown in Main (not connected). Once a connection is up it hides;
/// in the lobby/map Esc toggles it back as a modal pause overlay (cursor freed, local gameplay suspended via
/// <see cref="GameModeManager.SetPaused"/>). The Disconnect button is context-sensitive: in Main (not
/// connected) it is an application "Exit" (quits the build, or stops play mode in the editor) so the player
/// can leave without Alt+F4; on the map it becomes "Back to Lobby" for the match authority (ends the match);
/// everyone else (in the lobby, or a non-authority client on the map) gets a plain disconnect. Only the
/// visual <see cref="panelRoot"/> is toggled - this binder stays alive on its (always-active) canvas so it
/// keeps reading Esc while the panel is hidden.
/// </summary>
public class OnlineSessionBrowserUI : MonoBehaviour
{
[SerializeField] private OnlineSessionManager manager;
[Header("Buttons")]
[SerializeField] private Button hostButton;
[SerializeField] private Button refreshButton;
[SerializeField] private Button joinButton;
[SerializeField] private Button disconnectButton;
[Header("List")]
[Tooltip("Content RectTransform of the session ScrollRect (rows are added here).")]
[SerializeField] private RectTransform listContent;
[Tooltip("A row prototype kept disabled; cloned once per session. Children named 'Name' and 'Players' carry the text.")]
[SerializeField] private GameObject entryTemplate;
[Header("Status")]
[SerializeField] private TMP_Text statusLabel;
[Header("Overlay")]
[Tooltip("Visual container toggled by Esc while connected (the panel root). Must be a CHILD of this " +
"binder's object so the binder keeps running - and reading Esc - while the panel is hidden.")]
[SerializeField] private GameObject panelRoot;
[Tooltip("Background tint of the selected row.")]
[SerializeField] private Color selectedTint = new Color(0.26f, 0.52f, 0.96f, 1f);
[Tooltip("Background tint of an unselected row.")]
[SerializeField] private Color normalTint = new Color(1f, 1f, 1f, 0.05f);
private struct Row { public string id; public Image image; public GameObject go; }
private readonly List<Row> _rows = new List<Row>();
private string _selectedId;
private MatchManager _match; // local match state (scene kind + authority); found at runtime
private GameModeManager _gameMode; // pause hook for the modal overlay; found at runtime
private TMP_Text _disconnectLabel; // disconnect button caption (relabelled to "Back to Lobby")
private bool _open; // Esc-toggled visibility while connected
private bool _connected; // last-seen connection state (to detect transitions)
private Scene _lastScene; // last-seen active scene (to auto-close on a scene change)
private string _disconnectText; // last applied caption (avoids per-frame TMP churn)
private void Awake()
{
// The template is the prototype, never shown itself - layout ignores it while disabled.
if (entryTemplate) entryTemplate.SetActive(false);
_match = FindFirstObjectByType<MatchManager>();
_gameMode = FindFirstObjectByType<GameModeManager>();
}
private void OnEnable()
{
if (hostButton) hostButton.onClick.AddListener(OnHost);
if (refreshButton) refreshButton.onClick.AddListener(OnRefresh);
if (joinButton) joinButton.onClick.AddListener(OnJoin);
if (disconnectButton)
{
disconnectButton.onClick.AddListener(OnDisconnect);
_disconnectLabel = disconnectButton.GetComponentInChildren<TMP_Text>(true);
}
if (manager)
{
manager.Changed += OnManagerChanged;
manager.SessionsChanged += RebuildList;
}
_connected = manager && manager.IsConnected;
RebuildList();
OnManagerChanged();
ApplyVisibility();
}
private void OnDisable()
{
if (hostButton) hostButton.onClick.RemoveListener(OnHost);
if (refreshButton) refreshButton.onClick.RemoveListener(OnRefresh);
if (joinButton) joinButton.onClick.RemoveListener(OnJoin);
if (disconnectButton) disconnectButton.onClick.RemoveListener(OnDisconnect);
if (manager)
{
manager.Changed -= OnManagerChanged;
manager.SessionsChanged -= RebuildList;
}
}
private void Update()
{
if (!manager) return;
bool connected = manager.IsConnected;
// Auto-close the overlay if the scene changed underneath it (a remote ended/started the match, or
// we walked into Start Match) so it never lingers as a stale pause across a transition.
// Compared as Scene structs (handles), not names, so nothing is allocated per frame.
Scene scene = SceneManager.GetActiveScene();
if (connected && _open && _lastScene.IsValid() && scene != _lastScene)
SetOpen(false);
_lastScene = scene;
if (!connected) return; // Main: panel always shown, Esc ignored
bool inLobbyOrMap = _match && (_match.IsLocalSceneLobby || _match.IsLocalSceneMap);
if (inLobbyOrMap && EscapePressed())
SetOpen(!_open);
RefreshDisconnectLabel();
}
private void OnHost() { if (manager) manager.Host(); }
private void OnRefresh() { if (manager) manager.Refresh(); }
private void OnJoin() { if (manager && !string.IsNullOrEmpty(_selectedId)) manager.Join(_selectedId); }
private void OnDisconnect()
{
if (!manager) return;
// In Main (no session) the button is an application Exit, so the player can quit the build
// without Alt+F4.
if (!manager.IsConnected)
{
QuitApplication();
return;
}
// On the map, the match AUTHORITY's button means "Back to Lobby" (end the match for everyone)
// rather than leaving the session. Everyone else - in the lobby, or a non-authority client on the
// map - does a plain disconnect.
if (_match && _match.IsLocalSceneMap && _match.IsLocalMatchAuthority)
{
_match.RequestEndMatch();
SetOpen(false); // heading back to the lobby; close the overlay + resume input
return;
}
manager.Disconnect();
}
private static void QuitApplication()
{
#if UNITY_EDITOR
UnityEditor.EditorApplication.isPlaying = false;
#else
Application.Quit();
#endif
}
private void OnManagerChanged()
{
if (!manager) return;
bool connected = manager.IsConnected;
if (connected != _connected)
{
_connected = connected;
// Connection up (hosted/joined) -> hide the panel; Esc reopens it in the lobby/map.
// Back to Main (disconnect/shutdown) -> show it again and drop any pause.
_open = false;
if (!connected && _gameMode) _gameMode.SetPaused(false);
ApplyVisibility();
}
if (statusLabel) statusLabel.text = manager.Status;
UpdateButtons();
RefreshDisconnectLabel();
}
private void SetOpen(bool open)
{
_open = open;
ApplyVisibility();
if (_gameMode) _gameMode.SetPaused(open);
}
private void ApplyVisibility()
{
bool show = !(manager && manager.IsConnected) || _open;
if (panelRoot && panelRoot.activeSelf != show)
panelRoot.SetActive(show);
}
private void RefreshDisconnectLabel()
{
if (!_disconnectLabel) return;
string text;
if (!(manager && manager.IsConnected))
text = "Exit"; // Main: quit the application
else if (_match && _match.IsLocalSceneMap && _match.IsLocalMatchAuthority)
text = "Back to Lobby";
else
text = "Disconnect";
if (text != _disconnectText)
{
_disconnectText = text;
_disconnectLabel.text = text;
}
}
private void UpdateButtons()
{
if (!manager) return;
bool busy = manager.IsBusy;
bool connected = manager.IsConnected;
bool hasSelection = !string.IsNullOrEmpty(_selectedId);
if (hostButton) hostButton.interactable = !busy && !connected;
if (refreshButton) refreshButton.interactable = !busy && !connected;
if (joinButton) joinButton.interactable = !busy && !connected && hasSelection;
// Usable whenever idle: connected -> Disconnect / Back to Lobby; in Main -> Exit.
if (disconnectButton) disconnectButton.interactable = !busy;
}
private void RebuildList()
{
for (int i = 0; i < _rows.Count; i++)
if (_rows[i].go) Destroy(_rows[i].go);
_rows.Clear();
if (manager && entryTemplate && listContent)
{
var sessions = manager.Sessions;
bool selectionStillPresent = false;
for (int i = 0; i < sessions.Count; i++)
{
var s = sessions[i];
var go = Instantiate(entryTemplate, listContent);
go.SetActive(true);
var nameText = FindText(go.transform, "Name");
if (nameText) nameText.text = s.Name;
var playersText = FindText(go.transform, "Players");
if (playersText)
{
int taken = Mathf.Max(0, s.MaxPlayers - s.AvailableSlots);
playersText.text = $"{taken}/{s.MaxPlayers}";
}
string id = s.Id; // capture per-row for the click closure
var button = go.GetComponent<Button>();
if (button) button.onClick.AddListener(() => Select(id));
_rows.Add(new Row { id = id, image = go.GetComponent<Image>(), go = go });
if (id == _selectedId) selectionStillPresent = true;
}
if (!selectionStillPresent) _selectedId = null;
}
ApplyTints();
UpdateButtons();
}
private void Select(string id)
{
_selectedId = id;
ApplyTints();
UpdateButtons();
}
private void ApplyTints()
{
for (int i = 0; i < _rows.Count; i++)
if (_rows[i].image)
_rows[i].image.color = _rows[i].id == _selectedId ? selectedTint : normalTint;
}
private static TMP_Text FindText(Transform root, string childName)
{
var t = root.Find(childName);
return t ? t.GetComponent<TMP_Text>() : null;
}
// NOT TypingGuard-gated (GameplayKeyPressed): Esc must close the panel even while a text field has focus.
private static bool EscapePressed()
{
#if ENABLE_INPUT_SYSTEM
return InputUtil.KeyPressedThisFrame(Key.Escape);
#else
return InputUtil.KeyPressedThisFrame(KeyCode.Escape);
#endif
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c001d18ade3f180488179878fbaa3eef