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

+125 -67
View File
@@ -1,59 +1,84 @@
using System.Collections.Generic;
using System.IO;
using System.Text;
using UnityEngine;
using UnityEngine.SceneManagement;
public partial class MeshPainter
{
// --- Persistence: save the painted maps to disk and reload them next session ---
// --- Persistence: named paint slots, saved/loaded explicitly from the HUD ---
//
// The painter no longer auto-loads on start or auto-saves on quit; painting is volatile until the
// player explicitly saves it to a named slot, and a fresh session always starts from the material
// seed. Saved slots live in a single global library folder (role/object independent), so any avatar
// can load any saved painting as a camouflage preset.
[Header("Persistence")]
[Tooltip("Save the painted maps to disk on quit / play-stop and reload them on the next " +
"start, so the painting persists across sessions. Off = paint is volatile (as before).")]
public bool persistPaint = true;
[Tooltip("Save file identifier. Empty = derived from the active scene + object name. Set " +
"explicitly to keep a save stable across object renames or to share one save.")]
public string saveId = "";
// Raw, bit-exact map dump: 'CHMP' magic + version + width + height + map count, then each
// map's canonical Color32 bytes. Raw (not PNG) avoids any sRGB/alpha-premultiply ambiguity
// and round-trips the exact canonical bytes ReadCanonical produces.
// Raw, bit-exact map dump: 'CHMP' magic + version + name + width + height + map count, then each
// map's canonical Color32 bytes. Raw (not PNG) avoids any sRGB/alpha-premultiply ambiguity and
// round-trips the exact canonical bytes ReadCanonical produces.
private const uint PaintFileMagic = 0x504D4843; // 'CHMP' little-endian
private const int PaintFileVersion = 1;
private const int PaintFileVersion = 2; // v2: added the display-name header
private const string PaintFileExt = ".paint";
private bool _savedOnTeardown; // guards against a double save across OnApplicationQuit + OnDestroy
private static string PaintSaveDir => Path.Combine(Application.persistentDataPath, "MeshPaint");
private string PaintSavePath()
{
string key = string.IsNullOrEmpty(saveId)
? SanitizeKey(SceneManager.GetActiveScene().name + "_" + gameObject.name)
: SanitizeKey(saveId);
return Path.Combine(PaintSaveDir, key + ".paint");
}
private static string PaintLibraryDir =>
Path.Combine(Application.persistentDataPath, "MeshPaint", "Saved");
private static string SanitizeKey(string s)
{
if (string.IsNullOrEmpty(s)) return "unnamed";
foreach (char c in Path.GetInvalidFileNameChars()) s = s.Replace(c, '_');
return s.Replace(' ', '_');
}
/// <summary>Reads all maps from the active backend (canonical layout) and writes them to the save file.</summary>
public void SavePaint()
/// <summary>Disk path for a slot. The display name lives inside the file; the file name is a sanitized key.</summary>
private static string PathForName(string name) =>
Path.Combine(PaintLibraryDir, SanitizeKey(name) + PaintFileExt);
/// <summary>True if a slot with this name already exists (used to block overwriting on save).</summary>
public static bool PaintExists(string name) =>
!string.IsNullOrWhiteSpace(name) && File.Exists(PathForName(name));
/// <summary>Display names of every saved slot, sorted alphabetically. Each name is read from the file header.</summary>
public static List<string> ListSavedPaintings()
{
if (!persistPaint || !_setupValid) return;
var names = new List<string>();
try
{
if (!Directory.Exists(PaintLibraryDir)) return names;
foreach (string path in Directory.GetFiles(PaintLibraryDir, "*" + PaintFileExt))
{
string n = ReadHeaderName(path);
if (!string.IsNullOrEmpty(n)) names.Add(n);
}
}
catch (System.Exception e)
{
Debug.LogWarning($"[MeshPainter] Listing saved paintings failed: {e.Message}");
}
names.Sort(System.StringComparer.CurrentCultureIgnoreCase);
return names;
}
/// <summary>
/// Writes the current painted maps to a new named slot. Returns false without writing if the painter
/// is not ready or a slot with this name already exists (no silent overwrite).
/// </summary>
public bool SavePaintAs(string name)
{
if (!_setupValid || string.IsNullOrWhiteSpace(name)) return false;
name = name.Trim();
if (PaintExists(name)) return false;
try
{
Directory.CreateDirectory(PaintSaveDir);
Directory.CreateDirectory(PaintLibraryDir);
int n = textureSize * textureSize;
using (var fs = new FileStream(PaintSavePath(), FileMode.Create, FileAccess.Write))
using (var fs = new FileStream(PathForName(name), FileMode.Create, FileAccess.Write))
using (var w = new BinaryWriter(fs))
{
w.Write(PaintFileMagic);
w.Write(PaintFileVersion);
w.Write(name); // length-prefixed UTF8 (BinaryWriter.Write(string))
w.Write(textureSize);
w.Write(textureSize);
w.Write(MAP_COUNT);
@@ -74,41 +99,98 @@ public partial class MeshPainter
w.Write(bytes, 0, count * 4);
}
}
return true;
}
catch (System.Exception e)
{
Debug.LogWarning($"[MeshPainter] Saving paint failed: {e.Message}", this);
Debug.LogWarning($"[MeshPainter] Saving paint '{name}' failed: {e.Message}", this);
return false;
}
}
/// <summary>
/// Loads the saved maps in canonical layout for seeding, or null if none / incompatible.
/// Per-map null entries are tolerated by both backends (they fall back to the material seed).
/// Loads a named slot and re-seeds the active backend with it, replacing the current painting.
/// Returns false if the slot is missing or incompatible (the current painting is left untouched).
/// </summary>
private Color32[][] LoadPaintMaps()
public bool LoadPaintByName(string name)
{
if (!persistPaint) return null;
if (!_setupValid) return false;
string path = PaintSavePath();
Color32[][] canonical = ReadMaps(PathForName(name));
if (canonical == null) return false;
// Re-seed exactly like SwitchBackend / ApplyPaintSnapshot, so the island/dilation seeding path
// runs and the edge padding is rebuilt correctly for the loaded content.
if (_gpuActive) TeardownGpu();
else TeardownCpu();
SetupBackend(useGpu, canonical);
_hasLastScreen = false;
return true;
}
/// <summary>Deletes a named slot. Returns true if a file was removed.</summary>
public static bool DeletePaint(string name)
{
try
{
string path = PathForName(name);
if (!File.Exists(path)) return false;
File.Delete(path);
return true;
}
catch (System.Exception e)
{
Debug.LogWarning($"[MeshPainter] Deleting paint '{name}' failed: {e.Message}");
return false;
}
}
/// <summary>Reads just the display-name header of a slot file (or the file name as a fallback).</summary>
private static string ReadHeaderName(string path)
{
try
{
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read))
using (var r = new BinaryReader(fs, Encoding.UTF8))
{
if (r.ReadUInt32() != PaintFileMagic) return null;
if (r.ReadInt32() != PaintFileVersion) return null;
return r.ReadString();
}
}
catch
{
return Path.GetFileNameWithoutExtension(path);
}
}
/// <summary>
/// Reads all maps from a slot file in canonical layout, or null if missing / incompatible. Per-map
/// null entries are tolerated by both backends (they fall back to the material seed).
/// </summary>
private Color32[][] ReadMaps(string path)
{
if (!File.Exists(path)) return null;
try
{
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read))
using (var r = new BinaryReader(fs))
using (var r = new BinaryReader(fs, Encoding.UTF8))
{
if (r.ReadUInt32() != PaintFileMagic) return null;
int ver = r.ReadInt32();
if (ver != PaintFileVersion) return null;
r.ReadString(); // display name (header only; not needed for the pixel load)
int w = r.ReadInt32();
int h = r.ReadInt32();
int maps = r.ReadInt32();
// Resolution / version change -> ignore the save rather than smear mismatched
// data; the brush re-seeds from the material instead.
if (ver != PaintFileVersion || w != textureSize || h != textureSize)
// Resolution change -> ignore rather than smear mismatched data; the brush re-seeds
// from the material instead.
if (w != textureSize || h != textureSize)
{
Debug.LogWarning($"[MeshPainter] Saved paint ignored (got {w}x{h} v{ver}, expected " +
$"{textureSize}x{textureSize} v{PaintFileVersion}). Re-seeding from material.", this);
Debug.LogWarning($"[MeshPainter] Saved paint ignored (got {w}x{h}, expected " +
$"{textureSize}x{textureSize}).", this);
return null;
}
@@ -137,28 +219,4 @@ public partial class MeshPainter
return null;
}
}
/// <summary>Saves once during teardown (OnApplicationQuit and OnDestroy both call this; only the first writes).</summary>
private void SavePaintOnTeardown()
{
if (_savedOnTeardown) return;
_savedOnTeardown = true;
SavePaint();
}
/// <summary>Deletes this painter's save file (next start re-seeds from the material). Editor convenience.</summary>
[ContextMenu("Clear Saved Paint")]
public void ClearSavedPaint()
{
try
{
string path = PaintSavePath();
if (File.Exists(path)) File.Delete(path);
Debug.Log($"[MeshPainter] Cleared saved paint: {path}", this);
}
catch (System.Exception e)
{
Debug.LogWarning($"[MeshPainter] Clearing saved paint failed: {e.Message}", this);
}
}
}
+3 -11
View File
@@ -299,9 +299,9 @@ public partial class MeshPainter : MonoBehaviour
else if (_materialInstance.HasProperty(ColorId)) _materialInstance.SetColor(ColorId, Color.white);
BuildSeedColors();
// Persistence: seed from the saved maps if present (per-map null falls back to the
// material seed), otherwise from the material as before.
SetupBackend(useGpu, LoadPaintMaps() ?? _seedCanonical);
// Always start fresh from the material seed. Saved paintings are loaded explicitly from the
// HUD library (MeshPainter.Persistence), never auto-loaded on start.
SetupBackend(useGpu, _seedCanonical);
ConfigureMaterialPbr();
EnsureProceduralMask();
@@ -854,16 +854,8 @@ public partial class MeshPainter : MonoBehaviour
return es && es.IsPointerOverGameObject();
}
// Quit / play-stop (and standalone exit) fire OnApplicationQuit before teardown; persist
// here so the painted maps survive into the next session.
private void OnApplicationQuit() => SavePaintOnTeardown();
private void OnDestroy()
{
// Save before tearing the backend down (ReadCanonical needs the live maps). Covers a
// scene unload that does not raise OnApplicationQuit; the once-guard prevents a re-save.
SavePaintOnTeardown();
if (_bakedMesh && !_baker) Destroy(_bakedMesh); // _baker owns its mesh; only free our own
if (_materialInstance) Destroy(_materialInstance);
if (_proceduralMask) Destroy(_proceduralMask);
+269
View File
@@ -0,0 +1,269 @@
using System;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
/// <summary>
/// Binder for the "Paintings" library panel. The panel's visual layout (Panel_Paintings, the scrollable
/// list, an entry template, and the Save / Confirm dialogs) is authored inside the PaintHudPanel layout
/// prefab - this code only finds those objects by their stable names and wires the behaviour, exactly like
/// <see cref="WirePanel"/> does for the brush widgets. The list is populated by cloning the authored
/// Lib_EntryTemplate per saved slot. Save opens a name dialog (blocked if the name already exists, no
/// overwrite); Load and Delete go through a confirmation dialog. Loading replaces the current painting and,
/// when networked, re-broadcasts the loaded state to every other peer.
/// </summary>
public partial class PaintHud
{
// Stable widget names, matching the objects authored in the PaintHudPanel prefab.
private const string LibListContent = "Lib_ListContent";
private const string LibEntryTemplate = "Lib_EntryTemplate";
private const string LibSaveButton = "Lib_Save";
private const string LibLoadButton = "Lib_Load";
private const string LibDeleteButton = "Lib_Delete";
private const string LibSaveDialog = "Lib_SaveDialog";
private const string LibNameInput = "Lib_NameInput";
private const string LibSaveInfo = "Lib_SaveInfo";
private const string LibSaveOk = "Lib_SaveOk";
private const string LibSaveCancel = "Lib_SaveCancel";
private const string LibConfirmDialog = "Lib_ConfirmDialog";
private const string LibConfirmText = "Lib_ConfirmText";
private const string LibConfirmOk = "Lib_ConfirmOk";
private const string LibConfirmCancel = "Lib_ConfirmCancel";
private bool _libraryBound;
private RectTransform _listContent;
private GameObject _entryTemplate;
private Button _saveButton, _loadButton, _deleteButton;
private string _selectedPainting;
// Save dialog
private GameObject _saveDialog;
private TMP_InputField _nameInput;
private TMP_Text _saveInfo;
private Button _saveOkButton;
// Confirmation dialog (reused for Load + Delete)
private GameObject _confirmDialog;
private TMP_Text _confirmText;
private Button _confirmButton;
private Action _confirmAction;
private struct EntryRef { public string name; public Image bg; }
private readonly List<EntryRef> _entries = new List<EntryRef>();
private static readonly Color EntryNormal = new Color(0.18f, 0.18f, 0.22f, 1f);
private static readonly Color EntrySelected = new Color(0.20f, 0.45f, 0.80f, 1f);
private static readonly Color OkAccent = new Color(0.20f, 0.45f, 0.80f, 1f);
private static readonly Color OkDanger = new Color(0.62f, 0.26f, 0.26f, 1f);
// --- Bind (find authored objects, hook listeners) ---
/// <summary>
/// Binds the authored library panel under <paramref name="root"/> (the instantiated layout prefab).
/// Idempotent: binds exactly once. No-op if the prefab carries no library objects.
/// </summary>
private void BindLibrary(Transform root)
{
if (_libraryBound || !root) return;
_listContent = FindDeep(root, LibListContent) as RectTransform;
var template = FindDeep(root, LibEntryTemplate);
_entryTemplate = template ? template.gameObject : null;
if (!_listContent || !_entryTemplate) return; // panel not present in this layout
_entryTemplate.SetActive(false);
_saveButton = Find<Button>(root, LibSaveButton);
_loadButton = Find<Button>(root, LibLoadButton);
_deleteButton = Find<Button>(root, LibDeleteButton);
Hook(_saveButton, OnSaveClicked);
Hook(_loadButton, OnLoadClicked);
Hook(_deleteButton, OnDeleteClicked);
var sd = FindDeep(root, LibSaveDialog);
_saveDialog = sd ? sd.gameObject : null;
_nameInput = Find<TMP_InputField>(root, LibNameInput);
_saveInfo = Find<TMP_Text>(root, LibSaveInfo);
_saveOkButton = Find<Button>(root, LibSaveOk);
Hook(_saveOkButton, ConfirmSave);
Hook(Find<Button>(root, LibSaveCancel), () => { if (_saveDialog) _saveDialog.SetActive(false); });
if (_nameInput) _nameInput.onValueChanged.AddListener(_ => ValidateSaveName());
var cd = FindDeep(root, LibConfirmDialog);
_confirmDialog = cd ? cd.gameObject : null;
_confirmText = Find<TMP_Text>(root, LibConfirmText);
_confirmButton = Find<Button>(root, LibConfirmOk);
Hook(_confirmButton, OnConfirmClicked);
Hook(Find<Button>(root, LibConfirmCancel), () => { if (_confirmDialog) _confirmDialog.SetActive(false); });
HideDialogs();
_libraryBound = true;
UpdateButtonStates();
}
private static void Hook(Button button, Action onClick)
{
if (!button || onClick == null) return;
button.onClick.RemoveAllListeners();
button.onClick.AddListener(() => onClick());
}
// --- List ---
private void RefreshList()
{
if (!_listContent || !_entryTemplate) return;
for (int i = _listContent.childCount - 1; i >= 0; i--)
{
var child = _listContent.GetChild(i).gameObject;
if (child != _entryTemplate) Destroy(child);
}
_entries.Clear();
var names = MeshPainter.ListSavedPaintings();
if (!string.IsNullOrEmpty(_selectedPainting) && !names.Contains(_selectedPainting))
_selectedPainting = null;
foreach (var name in names)
{
var go = Instantiate(_entryTemplate, _listContent);
go.name = "Lib_Entry";
go.SetActive(true);
var bg = go.GetComponent<Image>();
if (bg) bg.color = name == _selectedPainting ? EntrySelected : EntryNormal;
var label = go.GetComponentInChildren<TMP_Text>(true);
if (label) label.text = name;
var btn = go.GetComponent<Button>();
if (btn)
{
string captured = name;
btn.onClick.RemoveAllListeners();
btn.onClick.AddListener(() => SelectPainting(captured));
}
_entries.Add(new EntryRef { name = name, bg = bg });
}
UpdateButtonStates();
}
private void SelectPainting(string name)
{
_selectedPainting = name;
RefreshHighlights();
UpdateButtonStates();
}
private void RefreshHighlights()
{
foreach (var e in _entries)
if (e.bg) e.bg.color = e.name == _selectedPainting ? EntrySelected : EntryNormal;
}
private void UpdateButtonStates()
{
bool hasPainter = painter;
bool hasSel = !string.IsNullOrEmpty(_selectedPainting);
if (_saveButton) _saveButton.interactable = hasPainter;
if (_loadButton) _loadButton.interactable = hasPainter && hasSel;
if (_deleteButton) _deleteButton.interactable = hasSel;
}
private void HideDialogs()
{
if (_saveDialog) _saveDialog.SetActive(false);
if (_confirmDialog) _confirmDialog.SetActive(false);
}
// --- Actions ---
private void OnSaveClicked()
{
if (!painter || !_saveDialog) return;
if (_nameInput) _nameInput.text = "";
if (_saveInfo) _saveInfo.text = "";
_saveDialog.SetActive(true);
_saveDialog.transform.SetAsLastSibling(); // a dragged panel may have moved itself in front; stay on top
ValidateSaveName();
if (_nameInput) _nameInput.ActivateInputField();
}
private void ValidateSaveName()
{
string name = _nameInput ? _nameInput.text.Trim() : "";
bool empty = string.IsNullOrEmpty(name);
bool exists = !empty && MeshPainter.PaintExists(name);
if (_saveOkButton) _saveOkButton.interactable = !empty && !exists && painter;
if (_saveInfo) _saveInfo.text = exists ? "A painting with this name already exists." : "";
}
private void ConfirmSave()
{
if (!painter || !_nameInput) return;
string name = _nameInput.text.Trim();
if (string.IsNullOrEmpty(name) || MeshPainter.PaintExists(name)) { ValidateSaveName(); return; }
if (painter.SavePaintAs(name))
{
_selectedPainting = name;
if (_saveDialog) _saveDialog.SetActive(false);
RefreshList();
}
else if (_saveInfo)
{
_saveInfo.text = "Save failed.";
}
}
private void OnLoadClicked()
{
if (!painter || string.IsNullOrEmpty(_selectedPainting)) return;
string name = _selectedPainting;
ShowConfirm($"Load \"{name}\"?\nThe current painting will be lost.", OkAccent, () => DoLoad(name));
}
private void OnDeleteClicked()
{
if (string.IsNullOrEmpty(_selectedPainting)) return;
string name = _selectedPainting;
ShowConfirm($"Delete \"{name}\"?", OkDanger, () => DoDelete(name));
}
private void DoLoad(string name)
{
if (!painter || !painter.LoadPaintByName(name)) return;
// Re-sync the loaded baseline to every other peer (no-op offline / single-player).
var sync = painter.GetComponentInParent<PlayerPaintSync>();
if (sync) sync.BroadcastLoadedPaint();
}
private void DoDelete(string name)
{
MeshPainter.DeletePaint(name);
if (_selectedPainting == name) _selectedPainting = null;
RefreshList();
}
private void ShowConfirm(string message, Color okColor, Action onConfirm)
{
if (!_confirmDialog) return;
if (_confirmText) _confirmText.text = message;
_confirmAction = onConfirm;
if (_confirmButton && _confirmButton.targetGraphic is Image okImg) okImg.color = okColor;
_confirmDialog.SetActive(true);
_confirmDialog.transform.SetAsLastSibling(); // a dragged panel may have moved itself in front; stay on top
}
private void OnConfirmClicked()
{
var act = _confirmAction;
_confirmAction = null;
if (_confirmDialog) _confirmDialog.SetActive(false);
act?.Invoke();
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 059854c655f028c4ba5f9b4b0bccdbb6
+12 -1
View File
@@ -117,6 +117,9 @@ public partial class PaintHud : MonoBehaviour
_panel = go;
}
// The paint library now lives inside the authored layout prefab; bind its widgets like the brush panel.
BindLibrary(_panel ? _panel.transform : null);
// Bind to a painter as soon as one exists. With networked spawn-on-connect there is none
// at scene start; BindLocalPlayer then raises PainterChanged when the local avatar spawns
// and OnPainterChanged wires the widgets. In a non-networked scene the painter is already
@@ -155,6 +158,8 @@ public partial class PaintHud : MonoBehaviour
// Restore the saved panel positions + widget values now that the painter exists.
RestoreHudState();
UpdateButtonStates(); // enable Save now that a painter is bound
}
private void OnDestroy()
@@ -165,7 +170,13 @@ public partial class PaintHud : MonoBehaviour
private void OnModeChanged(GameMode mode)
{
if (_panel) _panel.SetActive(mode is PaintMode);
bool paint = mode is PaintMode;
if (_panel) _panel.SetActive(paint); // toggles the brush panels and the library panel together
if (paint)
{
if (_libraryBound) RefreshList(); // pick up slots saved/changed since last shown
}
else HideDialogs();
}
private static Font LoadFont()
@@ -1,158 +0,0 @@
using System;
using System.IO;
using UnityEngine;
using UnityEngine.SceneManagement;
public partial class PlayerCharacterController
{
// --- Persistence: player position, rotation and wall-climb state ---
//
// Saved on quit / play-stop and restored on the next start, so the player resumes exactly
// where (and how) they left off - including hanging on a wall. This is small structured
// state, so it goes to a human-readable JSON file (like PaintHud.Persistence), not the raw
// byte dump the painter uses for its bulk pixel maps.
[Header("Persistence")]
[Tooltip("Save the player's position, rotation and wall-climb state on quit / play-stop and " +
"restore them on the next start. Off = the player starts at its authored scene transform.")]
public bool persistPlayer = true;
[Tooltip("Save file identifier. Empty = derived from the active scene + object name. Set " +
"explicitly to keep a save stable across object renames.")]
public string playerSaveId = "";
private const int PlayerFileVersion = 1;
private bool _playerSavedOnTeardown; // guards a double save across OnApplicationQuit + OnDestroy
private static string PlayerSaveDir => Path.Combine(Application.persistentDataPath, "MeshPaint");
private string PlayerSavePath()
{
string key = string.IsNullOrEmpty(playerSaveId)
? SanitizePlayerKey(SceneManager.GetActiveScene().name + "_" + gameObject.name)
: SanitizePlayerKey(playerSaveId);
return Path.Combine(PlayerSaveDir, key + ".player");
}
private static string SanitizePlayerKey(string s)
{
foreach (char c in Path.GetInvalidFileNameChars()) s = s.Replace(c, '_');
return s.Replace(' ', '_');
}
[Serializable]
private class PlayerState
{
public int version = PlayerFileVersion;
public Vector3 position;
public Quaternion rotation;
// Wall-climb attachment. The normal/offset/yaw/left-ground flags fully describe the grip;
// ProbeWall re-validates contact on the next frame, so a grip restored away from any wall
// drops cleanly after the grace period rather than freezing the player in mid-air.
public bool isWallClimbing;
public Vector3 wallNormal;
public float wallOffset;
public float wallYaw;
public bool climbLeftGround;
}
void Start() => RestorePlayerState();
private void OnApplicationQuit() => SavePlayerOnTeardown();
private void OnDestroy() => SavePlayerOnTeardown();
/// <summary>Saves once during teardown (OnApplicationQuit and OnDestroy both call this; only the first writes).</summary>
private void SavePlayerOnTeardown()
{
if (_playerSavedOnTeardown) return;
_playerSavedOnTeardown = true;
SavePlayerState();
}
/// <summary>Writes the current transform and wall-climb state to disk.</summary>
public void SavePlayerState()
{
if (!persistPlayer) return;
try
{
var state = new PlayerState
{
position = transform.position,
rotation = transform.rotation,
isWallClimbing = _isWallClimbing,
wallNormal = _wallNormal,
wallOffset = _wallOffset,
wallYaw = _wallYaw,
climbLeftGround = _climbLeftGround,
};
Directory.CreateDirectory(PlayerSaveDir);
File.WriteAllText(PlayerSavePath(), JsonUtility.ToJson(state, true));
}
catch (Exception e)
{
Debug.LogWarning($"[PlayerCharacterController] Saving player state failed: {e.Message}", this);
}
}
/// <summary>Restores the saved transform and wall-climb state, or does nothing if there is no save.</summary>
private void RestorePlayerState()
{
if (!persistPlayer) return;
var state = LoadPlayerState();
if (state == null) return;
// Teleport through the CharacterController: toggling enabled clears its cached internal
// position so it neither snaps back nor fights the restored transform.
bool wasEnabled = _controller && _controller.enabled;
if (_controller) _controller.enabled = false;
transform.SetPositionAndRotation(state.position, state.rotation);
if (_controller) _controller.enabled = wasEnabled;
_isWallClimbing = state.isWallClimbing;
if (state.wallNormal.sqrMagnitude > 1e-6f)
_wallNormal = state.wallNormal.normalized;
_wallOffset = state.wallOffset;
_wallYaw = state.wallYaw;
_climbLeftGround = state.climbLeftGround;
if (_isWallClimbing)
_verticalVelocity = 0f; // a restored grip starts at rest, not mid-fall
}
private PlayerState LoadPlayerState()
{
string path = PlayerSavePath();
if (!File.Exists(path)) return null;
try
{
var state = JsonUtility.FromJson<PlayerState>(File.ReadAllText(path));
if (state == null || state.version != PlayerFileVersion) return null;
return state;
}
catch (Exception e)
{
Debug.LogWarning($"[PlayerCharacterController] Loading player state failed: {e.Message}", this);
return null;
}
}
/// <summary>Deletes the saved player state (next start uses the authored scene transform). Editor convenience.</summary>
[ContextMenu("Clear Saved Player State")]
public void ClearSavedPlayerState()
{
try
{
string path = PlayerSavePath();
if (File.Exists(path)) File.Delete(path);
Debug.Log($"[PlayerCharacterController] Cleared saved player state: {path}", this);
}
catch (Exception e)
{
Debug.LogWarning($"[PlayerCharacterController] Clearing saved player state failed: {e.Message}", this);
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 527b09f5e99500640b40acffaa58f011
+1 -1
View File
@@ -33,7 +33,7 @@ using UnityEngine.InputSystem;
/// </summary>
[RequireComponent(typeof(CharacterController))]
[DisallowMultipleComponent]
public partial class PlayerCharacterController : MonoBehaviour
public class PlayerCharacterController : MonoBehaviour
{
[Header("Movement")]
[Tooltip("Horizontal movement speed (units/second).")]
+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();
}
}
@@ -1,126 +0,0 @@
using System;
using System.IO;
using UnityEngine;
using UnityEngine.SceneManagement;
public partial class PlayerPoseController
{
// --- Persistence: the selected pose index ---
//
// Saved on quit / play-stop and restored on the next start, so the player keeps the pose they
// had selected. Mirrors the project's other X.Persistence.cs partials (small JSON state under
// the shared MeshPaint folder).
[Header("Persistence")]
[Tooltip("Save the selected pose index on quit / play-stop and restore it on the next start. " +
"Off = the selection starts at the default pose.")]
public bool persistPose = true;
[Tooltip("Save file identifier. Empty = derived from the active scene + object name. Set " +
"explicitly to keep a save stable across object renames.")]
public string poseSaveId = "";
private const int PoseFileVersion = 1;
private bool _poseSavedOnTeardown; // guards a double save across OnApplicationQuit + OnDestroy
private static string PoseSaveDir => Path.Combine(Application.persistentDataPath, "MeshPaint");
private string PoseSavePath()
{
string key = string.IsNullOrEmpty(poseSaveId)
? SanitizePoseKey(SceneManager.GetActiveScene().name + "_" + gameObject.name)
: SanitizePoseKey(poseSaveId);
return Path.Combine(PoseSaveDir, key + ".pose");
}
private static string SanitizePoseKey(string s)
{
foreach (char c in Path.GetInvalidFileNameChars()) s = s.Replace(c, '_');
return s.Replace(' ', '_');
}
[Serializable]
private class PoseState
{
public int version = PoseFileVersion;
public int index;
}
void Start() => RestorePoseState();
private void OnApplicationQuit() => SavePoseOnTeardown();
private void OnDestroy() => SavePoseOnTeardown();
/// <summary>Saves once during teardown (OnApplicationQuit and OnDestroy both call this; only the first writes).</summary>
private void SavePoseOnTeardown()
{
if (_poseSavedOnTeardown) return;
_poseSavedOnTeardown = true;
SavePoseState();
}
/// <summary>Writes the current pose index to disk.</summary>
public void SavePoseState()
{
// A network-driven remote avatar shares the local "Player(Clone)" save key with the owned avatar;
// persisting it would clobber the local player's saved pose with a replicated one.
if (_networkControlled || !persistPose) return;
try
{
var state = new PoseState { index = _currentIndex };
Directory.CreateDirectory(PoseSaveDir);
File.WriteAllText(PoseSavePath(), JsonUtility.ToJson(state, true));
}
catch (Exception e)
{
Debug.LogWarning($"[PlayerPoseController] Saving pose state failed: {e.Message}", this);
}
}
/// <summary>Restores the saved pose index, or does nothing if there is no save.</summary>
private void RestorePoseState()
{
// Skip on a network-driven remote avatar: its pose is replicated from the owner, and reading the
// local save would briefly show the wrong pose before the first network update arrives.
if (_networkControlled || !persistPose) return;
var state = LoadPoseState();
if (state == null) return;
SetPoseIndex(state.index);
}
private PoseState LoadPoseState()
{
string path = PoseSavePath();
if (!File.Exists(path)) return null;
try
{
var state = JsonUtility.FromJson<PoseState>(File.ReadAllText(path));
if (state == null || state.version != PoseFileVersion) return null;
return state;
}
catch (Exception e)
{
Debug.LogWarning($"[PlayerPoseController] Loading pose state failed: {e.Message}", this);
return null;
}
}
/// <summary>Deletes the saved pose state (next start uses the default pose). Editor convenience.</summary>
[ContextMenu("Clear Saved Pose State")]
public void ClearSavedPoseState()
{
try
{
string path = PoseSavePath();
if (File.Exists(path)) File.Delete(path);
Debug.Log($"[PlayerPoseController] Cleared saved pose state: {path}", this);
}
catch (Exception e)
{
Debug.LogWarning($"[PlayerPoseController] Clearing saved pose state failed: {e.Message}", this);
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 314a46cf262f78345a83e4bbe1c939f2
+1 -1
View File
@@ -34,7 +34,7 @@ using UnityEngine.InputSystem;
// Run after PlayerCharacterController (default order 0) so the sprint / locomotion state polled below is
// this frame's, and the per-pose capsule / camera offset resolve without a one-frame seam.
[DefaultExecutionOrder(100)]
public partial class PlayerPoseController : MonoBehaviour
public class PlayerPoseController : MonoBehaviour
{
[Header("Poses")]
[Tooltip("Selectable poses. Index 0 is the default pose that sprinting resets to.")]
@@ -1,143 +0,0 @@
using System;
using System.IO;
using UnityEngine;
using UnityEngine.SceneManagement;
public partial class ThirdPersonOrbitInput
{
// --- Persistence: third-person camera orbit (rotation + distance) ---
//
// Saved on quit / play-stop and restored on the next start, so the camera resumes at the same
// view. The camera is a CinemachineOrbitalFollow whose world position/rotation are derived each
// frame from the orbit axes (yaw/pitch) and radius relative to the follow target - so saving the
// literal transform would be pointless (Cinemachine overwrites it). The canonical state is the
// orbit yaw/pitch (= rotation) and radius (= distance); combined with the persisted player
// position this reproduces the exact same view. Small structured state -> human-readable JSON
// (like PlayerCharacterController.Persistence), not the painter's raw byte dump.
[Header("Persistence")]
[Tooltip("Save the camera orbit (yaw/pitch rotation and zoom distance) on quit / play-stop and " +
"restore it on the next start. Off = the camera starts at its authored orbit state.")]
public bool persistCamera = true;
[Tooltip("Save file identifier. Empty = derived from the active scene + object name. Set " +
"explicitly to keep a save stable across object renames.")]
public string cameraSaveId = "";
private const int CameraFileVersion = 1;
private bool _cameraSavedOnTeardown; // guards a double save across OnApplicationQuit + OnDestroy
private static string CameraSaveDir => Path.Combine(Application.persistentDataPath, "MeshPaint");
private string CameraSavePath()
{
string key = string.IsNullOrEmpty(cameraSaveId)
? SanitizeCameraKey(SceneManager.GetActiveScene().name + "_" + gameObject.name)
: SanitizeCameraKey(cameraSaveId);
return Path.Combine(CameraSaveDir, key + ".camera");
}
private static string SanitizeCameraKey(string s)
{
foreach (char c in Path.GetInvalidFileNameChars()) s = s.Replace(c, '_');
return s.Replace(' ', '_');
}
[Serializable]
private class CameraState
{
public int version = CameraFileVersion;
public float yaw; // CinemachineOrbitalFollow.HorizontalAxis.Value
public float pitch; // CinemachineOrbitalFollow.VerticalAxis.Value
public float radius; // CinemachineOrbitalFollow.Radius (zoom distance)
}
void Start() => RestoreCameraState();
private void OnApplicationQuit() => SaveCameraOnTeardown();
private void OnDestroy() => SaveCameraOnTeardown();
/// <summary>Saves once during teardown (OnApplicationQuit and OnDestroy both call this; only the first writes).</summary>
private void SaveCameraOnTeardown()
{
if (_cameraSavedOnTeardown) return;
_cameraSavedOnTeardown = true;
SaveCameraState();
}
/// <summary>Writes the current orbit yaw/pitch and radius to disk.</summary>
public void SaveCameraState()
{
if (!persistCamera || !_orbit) return;
try
{
var state = new CameraState
{
yaw = _orbit.HorizontalAxis.Value,
pitch = _orbit.VerticalAxis.Value,
radius = _orbit.Radius,
};
Directory.CreateDirectory(CameraSaveDir);
File.WriteAllText(CameraSavePath(), JsonUtility.ToJson(state, true));
}
catch (Exception e)
{
Debug.LogWarning($"[ThirdPersonOrbitInput] Saving camera state failed: {e.Message}", this);
}
}
/// <summary>Restores the saved orbit yaw/pitch and radius, or does nothing if there is no save.</summary>
private void RestoreCameraState()
{
if (!persistCamera || !_orbit) return;
var state = LoadCameraState();
if (state == null) return;
_orbit.HorizontalAxis.Value = state.yaw;
_orbit.VerticalAxis.Value = state.pitch;
// Clamp into the current zoom range (min/max may have changed in the inspector since saving)
// and seed the eased zoom target so the radius doesn't drift back toward the authored value.
float radius = Mathf.Clamp(state.radius, minRadius, maxRadius);
_orbit.Radius = radius;
_targetRadius = radius;
_zoomInit = true;
}
private CameraState LoadCameraState()
{
string path = CameraSavePath();
if (!File.Exists(path)) return null;
try
{
var state = JsonUtility.FromJson<CameraState>(File.ReadAllText(path));
if (state == null || state.version != CameraFileVersion) return null;
return state;
}
catch (Exception e)
{
Debug.LogWarning($"[ThirdPersonOrbitInput] Loading camera state failed: {e.Message}", this);
return null;
}
}
/// <summary>Deletes the saved camera state (next start uses the authored orbit). Editor convenience.</summary>
[ContextMenu("Clear Saved Camera State")]
public void ClearSavedCameraState()
{
try
{
string path = CameraSavePath();
if (File.Exists(path)) File.Delete(path);
Debug.Log($"[ThirdPersonOrbitInput] Cleared saved camera state: {path}", this);
}
catch (Exception e)
{
Debug.LogWarning($"[ThirdPersonOrbitInput] Clearing saved camera state failed: {e.Message}", this);
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 9680e7022f5587f49864d07bd74e49ee
+1 -1
View File
@@ -12,7 +12,7 @@ using UnityEngine.InputSystem;
/// </summary>
[RequireComponent(typeof(CinemachineOrbitalFollow))]
[DisallowMultipleComponent]
public partial class ThirdPersonOrbitInput : MonoBehaviour
public class ThirdPersonOrbitInput : MonoBehaviour
{
public enum OrbitLookMode
{