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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 059854c655f028c4ba5f9b4b0bccdbb6
|
||||
@@ -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
|
||||
@@ -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).")]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -12,7 +12,7 @@ using UnityEngine.InputSystem;
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(CinemachineOrbitalFollow))]
|
||||
[DisallowMultipleComponent]
|
||||
public partial class ThirdPersonOrbitInput : MonoBehaviour
|
||||
public class ThirdPersonOrbitInput : MonoBehaviour
|
||||
{
|
||||
public enum OrbitLookMode
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user