Replace auto paint persistence with a named painting library

Persistence rework:
- Drop auto-load on setup and auto-save on teardown; the painter now always
  seeds fresh from the material.
- Named slots under MeshPaint/Saved/<name>.paint via the existing CHMP
  machinery: SavePaintAs / LoadPaintByName / DeletePaint / ListSavedPaintings /
  PaintExists. Slot files carry the display name in the header.
- Remove the obsolete persistence partials (PlayerCharacterController,
  PlayerPoseController, ThirdPersonOrbitInput) and their references.

Paintings HUD panel:
- Authored Panel_Paintings + Save/Load/Delete dialogs into PaintHudPanel.prefab
  (TMP throughout); PaintHud.Library.cs is now a pure binder that clones the
  entry template per slot.
- Save dialog blocks empty/existing names; Load and Delete confirm first.
- Fix dialog z-order: open dialogs SetAsLastSibling so a dragged panel can no
  longer cover them.
- Lib_ListContent horizontal offsets corrected to 0 (was clipping entry labels
  out of the masked viewport).

Multiplayer:
- Loading a painting re-syncs to all peers via the snapshot broadcast path
  (BroadcastLoadedPaint), covering later late-joiners for free.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-06-27 18:39:20 +02:00
1 parent 56caa0edd8
commit ee482905f9
16 files changed
+4586 -521

No files matched your search

+86 -6
View File
@@ -74,9 +74,6 @@ public class PlayerPaintSync : NetworkBehaviour
{
// Remote replica: never paint from this machine's mouse; paint only from replicated data.
_painter.PaintingEnabled = false;
// Don't persist a replicated avatar: all clones share one save key, so remotes would
// clobber the local player's saved paint on teardown (same fix as the pose system).
_painter.persistPaint = false;
// Track the owner's emission intensity; seed the current (late-join) value right away.
_emissionIntensity.OnValueChanged += OnEmissionChanged;
@@ -200,12 +197,12 @@ public class PlayerPaintSync : NetworkBehaviour
// Everything the captured maps already contain: the owner's send counter for our own avatar,
// otherwise the last batch this server replica has applied.
int capturedSeq = IsOwner ? _seq : _seqHorizon;
StartCoroutine(SendSnapshot(p.Receive.SenderClientId, data, capturedSeq));
StartCoroutine(SendSnapshot(new[] { p.Receive.SenderClientId }, data, capturedSeq));
}
private IEnumerator SendSnapshot(ulong targetClientId, byte[] data, int capturedSeq)
private IEnumerator SendSnapshot(ulong[] targets, byte[] data, int capturedSeq)
{
var send = new ClientRpcParams { Send = new ClientRpcSendParams { TargetClientIds = new[] { targetClientId } } };
var send = new ClientRpcParams { Send = new ClientRpcSendParams { TargetClientIds = targets } };
BeginSnapshotClientRpc(data.Length, capturedSeq, send);
@@ -252,4 +249,87 @@ public class PlayerPaintSync : NetworkBehaviour
if (!_snapReadyToApply)
_awaitingSnapshot = false;
}
// ---- Load re-sync (owner -> server -> every other peer) ----
//
// Loading a saved painting replaces the owner's whole map set at once (no stamps), so the live
// stream cannot carry it. The owner captures a snapshot, uploads it to the server chunked (same
// throttling as the download), and the server re-broadcasts it to every other peer through the
// existing snapshot client RPCs - including the host's own replica via its client-id target. A
// cutoff sequence drops any in-flight/buffered stamps already superseded by the loaded baseline.
private byte[] _uploadBuffer; // server-side reassembly of the owner's uploaded snapshot
private int _uploadCutoff;
/// <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;
int cutoff = ++_seq; // every stamp up to here is now superseded by the loaded baseline
_seqHorizon = cutoff;
StartCoroutine(UploadSnapshot(data, cutoff));
}
private IEnumerator UploadSnapshot(byte[] data, int cutoff)
{
BeginUploadServerRpc(data.Length, cutoff);
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);
UploadChunkServerRpc(off, chunk);
if (++sentThisFrame >= snapshotChunksPerFrame)
{
sentThisFrame = 0;
yield return null; // spread the burst so the reliable window does not overflow
}
}
EndUploadServerRpc();
}
[ServerRpc]
private void BeginUploadServerRpc(int totalBytes, int cutoff)
{
_uploadBuffer = totalBytes > 0 ? new byte[totalBytes] : null;
_uploadCutoff = cutoff;
}
[ServerRpc]
private void UploadChunkServerRpc(int offset, byte[] chunk)
{
if (_uploadBuffer == null) return;
if (offset >= 0 && offset + chunk.Length <= _uploadBuffer.Length)
System.Array.Copy(chunk, 0, _uploadBuffer, offset, chunk.Length);
}
[ServerRpc]
private void EndUploadServerRpc()
{
if (_uploadBuffer == null) return;
// Fan out to every connected peer except the owner (who already loaded it locally). The host,
// when it is not the owner, is included by its client id and applies it like any other remote.
ulong[] targets = OtherClientIds(OwnerClientId);
if (targets.Length > 0)
StartCoroutine(SendSnapshot(targets, _uploadBuffer, _uploadCutoff));
_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();
}
}