Carry painting across lobby/map respawns with isolated snapshot transfers
Persist each owner's paint snapshot on the MatchManager across the despawn/respawn that happens on a lobby<->map scene switch, then reload and rebroadcast it onto the freshly spawned avatar so painted meshes survive the transition. Cleared on disconnect/shutdown to avoid stale carry across sessions. Isolate concurrent snapshot transfers by id so a respawning replica's late-join request and the carry broadcast no longer share one reassembly buffer (which decoded a mixed deflate blob -> "Corrupted data"), and drop transfers with an older cutoff so a blank late-join cannot overwrite the carried image. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
b00c415c8c
commit
9d6570f7fe
3 files changed
+120
-8
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
/// <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;
|
||||
}
|
||||
}
|
||||
@@ -238,6 +238,31 @@ public partial class MeshPainter
|
||||
SetHistoryBaseline(canonical, baselineId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owner-side: re-seeds the painter from an in-memory snapshot blob and resets history to it as a fresh
|
||||
/// local baseline - exactly like <see cref="LoadPaintByName"/> 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).
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
@@ -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<NetEvent> _buffered = new List<NetEvent>();
|
||||
|
||||
// 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<MeshPainter>(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)
|
||||
|
||||
Reference in new issue
Block a user