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:
1 parent
56caa0edd8
commit
ee482905f9
16 files changed
+4586
-521
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user