diff --git a/Assets/Scripts/MatchManager.cs b/Assets/Scripts/MatchManager.cs index a2d715f..4865028 100644 --- a/Assets/Scripts/MatchManager.cs +++ b/Assets/Scripts/MatchManager.cs @@ -97,6 +97,13 @@ public class MatchManager : MonoBehaviour // client every time. An independent System.Random decouples the pick from that shared global state. private readonly System.Random _roleRng = new System.Random(System.Guid.NewGuid().GetHashCode()); + // Painting carry across an avatar respawn (scene switch). Per-machine: the LOCAL owner's painting, + // parked here by its despawning PlayerPaintSync (that component is gone before the replacement spawns) + // and consumed once by the next owner avatar. This manager is persistent in Main, so it bridges the + // despawn->spawn gap. Cleared on teardown (ReturnToMainOnly) so a fresh session never inherits a + // previous one's painting; a normal respawn keeps Main loaded, so the parked copy survives it. + private byte[] _carriedPaint; + private void Awake() { if (Instance && Instance != this) { Destroy(this); return; } @@ -381,6 +388,10 @@ public class MatchManager : MonoBehaviour // 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); @@ -400,4 +411,15 @@ public class MatchManager : MonoBehaviour if (s.IsValid() && s.isLoaded && s != SceneManager.GetActiveScene()) SceneManager.UnloadSceneAsync(s); } + + /// Owner-side: park the local painting so the avatar a scene switch spawns next can restore it. + public void StoreCarriedPaint(byte[] data) => _carriedPaint = data; + + /// Owner-side: take (and clear) the parked painting for the freshly spawned avatar, or null if none. + public byte[] TakeCarriedPaint() + { + byte[] d = _carriedPaint; + _carriedPaint = null; + return d; + } } diff --git a/Assets/Scripts/MeshPainter.Net.cs b/Assets/Scripts/MeshPainter.Net.cs index 0a6759e..d716aee 100644 --- a/Assets/Scripts/MeshPainter.Net.cs +++ b/Assets/Scripts/MeshPainter.Net.cs @@ -238,6 +238,31 @@ public partial class MeshPainter SetHistoryBaseline(canonical, baselineId); } + /// + /// Owner-side: re-seeds the painter from an in-memory snapshot blob and resets history to it as a fresh + /// local baseline - exactly like does for a file, just from bytes. Used to + /// carry a painting onto a freshly spawned avatar across a scene-switch respawn; the caller then + /// BroadcastLoadedPaint()s it to peers via the same path a manual load uses. Returns false if the painter + /// is not ready or the blob is missing / incompatible (the current painting is left untouched). + /// + public bool LoadPaintFromSnapshot(byte[] compressed) + { + if (!_setupValid || compressed == null || compressed.Length == 0) return false; + + Color32[][] canonical; + try { canonical = DecodeSnapshot(compressed); } + catch (System.Exception e) + { + Debug.LogWarning($"[MeshPainter] Carry snapshot decode failed: {e.Message}", this); + return false; + } + if (canonical == null) return false; + + ReseedCanonical(canonical); + ResetHistoryToCurrent(canonical); + return true; + } + private Color32[][] DecodeSnapshot(byte[] compressed) { byte[] raw = Inflate(compressed); diff --git a/Assets/Scripts/PlayerPaintSync.cs b/Assets/Scripts/PlayerPaintSync.cs index 1dea744..17c6f97 100644 --- a/Assets/Scripts/PlayerPaintSync.cs +++ b/Assets/Scripts/PlayerPaintSync.cs @@ -27,6 +27,15 @@ using UnityEngine; /// sequence - so no stroke is lost or double-applied across the join window. The applied snapshot also /// becomes the joiner's history baseline. /// +/// Respawn carry: a scene switch (lobby <-> map) despawns the avatar and spawns a new prefab, which +/// would otherwise start blank. On despawn the owner parks a snapshot of its painting on the persistent +/// MatchManager; the replacement avatar's owner takes it on spawn and, once its painter is ready, loads it +/// and BroadcastLoadedPaint()s it - the exact path a manual load uses - so the painting follows the player +/// across the respawn for every peer. A real disconnect clears the parked copy, so sessions don't bleed. +/// On respawn this carry-broadcast and the replica's own late-join request both deliver a full snapshot at +/// once, so transfers are id-tagged (only the active one reassembles/applies) and cutoff-ordered (an older +/// snapshot never overwrites a newer one) - otherwise the two would share a buffer and decode corrupted. +/// /// Emission intensity is the one paint property that is NOT baked into the maps (it's a global /// multiplier on the material's _EmissionColor), so it can't ride the stamp/snapshot path. It rides its /// own owner-authoritative NetworkVariable; the remote painter's per-frame RefreshEmissionColor picks up @@ -98,6 +107,22 @@ public class PlayerPaintSync : NetworkBehaviour private bool _snapReadyToApply; // last chunk arrived; apply once the painter is ready private readonly List _buffered = new List(); + // A scene-switch respawn delivers TWO full snapshots to the same replica at once: this replica's own + // late-join RequestSnapshotRpc AND the owner's carry BroadcastLoadedPaint. Without isolation they share + // one reassembly buffer and one End() trigger, so a half-filled/mixed Deflate blob gets decoded -> the + // "Corrupted data" warning, paint never applies. Each outgoing transfer carries a unique id; only the + // active one fills the buffer and applies, and a stale chunk/End from a superseded transfer is ignored. + private int _activeXferId; + // Highest snapshot cutoff accepted so far. A transfer carrying OLDER state is dropped, so a late blank + // late-join (server replica still empty at capture) can't overwrite the newer carry. Equal is accepted + // so the owner-snapshot heal path (same cutoff, but the diverged peer genuinely needs it) still works. + private int _acceptedCutoffFloor = int.MinValue; + + // Owner-only: painting carried over from the avatar a scene switch just despawned (taken from the + // persistent MatchManager on spawn). Applied in LateUpdate once this fresh painter is ready, then + // rebroadcast to peers via the same path a manual load uses. Null = nothing to carry. + private byte[] _pendingCarry; + private void Awake() => _painter = GetComponentInChildren(true); public override void OnNetworkSpawn() @@ -110,6 +135,10 @@ public class PlayerPaintSync : NetworkBehaviour _painter.OnStampCommitted += OnStampCommitted; _painter.OnHistoryCheckpoint += OnLocalCheckpoint; _painter.OnHistoryRestore += OnLocalRestore; + + // Carry the painting from the avatar a scene switch just despawned (if any) onto this fresh one. + // Applied in LateUpdate once the painter has finished setup, then broadcast to peers. + if (MatchManager.Instance) _pendingCarry = MatchManager.Instance.TakeCarriedPaint(); } else { @@ -135,6 +164,12 @@ public class PlayerPaintSync : NetworkBehaviour { if (_painter && IsOwner) { + // Park the current painting so the avatar this scene switch spawns next can restore it. A real + // disconnect clears the parked copy (MatchManager.ReturnToMainOnly), so a fresh session never + // inherits it. Captured here, the latest possible point, so last-second strokes are included. + if (MatchManager.Instance && _painter.TryCaptureSnapshot(out byte[] snap)) + MatchManager.Instance.StoreCarriedPaint(snap); + _painter.OnStampCommitted -= OnStampCommitted; _painter.OnHistoryCheckpoint -= OnLocalCheckpoint; _painter.OnHistoryRestore -= OnLocalRestore; @@ -189,6 +224,20 @@ public class PlayerPaintSync : NetworkBehaviour if (!IsOwner) return; + // Restore a carried-over painting onto this fresh avatar once the painter is ready, then propagate + // it to peers exactly like a manual load (BroadcastLoadedPaint). One-shot: cleared whether or not it + // took, so a failed/empty carry never retries every frame. + if (_pendingCarry != null && _painter && _painter.IsPaintReady) + { + byte[] carry = _pendingCarry; + _pendingCarry = null; + if (_painter.LoadPaintFromSnapshot(carry)) + { + LogSync($"carried painting onto respawned avatar ({carry.Length} bytes); rebroadcasting to peers"); + BroadcastLoadedPaint(); + } + } + // Publish the owner's global emission intensity when the HUD slider changes it (delta-only; the // NetworkVariable dirties only on an actual change, so assigning the same value is a no-op). if (_painter && _emissionIntensity.Value != _painter.emissionIntensity) @@ -423,11 +472,16 @@ public class PlayerPaintSync : NetworkBehaviour 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(data.Length, capturedSeq, baselineId, send); + BeginSnapshotClientRpc(xferId, data.Length, capturedSeq, baselineId, send); int sentThisFrame = 0; for (int off = 0; off < data.Length; off += snapshotChunkBytes) @@ -435,7 +489,7 @@ public class PlayerPaintSync : NetworkBehaviour int len = Mathf.Min(snapshotChunkBytes, data.Length - off); var chunk = new byte[len]; System.Array.Copy(data, off, chunk, 0, len); - SnapshotChunkClientRpc(off, chunk, send); + SnapshotChunkClientRpc(xferId, off, chunk, send); if (++sentThisFrame >= snapshotChunksPerFrame) { @@ -444,31 +498,42 @@ public class PlayerPaintSync : NetworkBehaviour } } - EndSnapshotClientRpc(send); + EndSnapshotClientRpc(xferId, send); } [ClientRpc] - private void BeginSnapshotClientRpc(int totalBytes, int capturedSeq, int baselineId, ClientRpcParams p = default) + 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) + { + 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; - LogSync($"receiving snapshot ({totalBytes} bytes, cutoff {capturedSeq}, baseline {baselineId})"); + LogSync($"receiving snapshot #{xferId} ({totalBytes} bytes, cutoff {capturedSeq}, baseline {baselineId})"); } [ClientRpc] - private void SnapshotChunkClientRpc(int offset, byte[] chunk, ClientRpcParams p = default) + private void SnapshotChunkClientRpc(int xferId, int offset, byte[] chunk, ClientRpcParams p = default) { - if (_snapBuffer == null) return; + if (xferId != _activeXferId || _snapBuffer == null) return; // stale chunk from a superseded transfer if (offset >= 0 && offset + chunk.Length <= _snapBuffer.Length) System.Array.Copy(chunk, 0, _snapBuffer, offset, chunk.Length); } [ClientRpc] - private void EndSnapshotClientRpc(ClientRpcParams p = default) + 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)