using System; using System.Collections.Generic; using System.IO; using UnityEngine; public partial class PaintHud { // --- Persistence: panel positions + the widget-bound painter values --- // // Saved when state actually changes - a panel drop writes immediately (OnPanelMoved), brush-value // edits are coalesced and flushed shortly after they settle (LateUpdate debounce) - and restored on // the next start, so the HUD opens where you left it with the brush settings you last used. There is // deliberately NO save on quit/teardown: standalone builds do not guarantee a clean shutdown // (OnApplicationQuit is skipped on force-close / kill / crash), so a teardown-only save silently // lost the whole session's layout in the build. This is small structured state (a handful of // positions and scalars), so it goes to a human-readable JSON file - unlike the painter's bulk pixel // maps (MeshPainter.Persistence) which use a raw byte dump. The two saves are independent: this one // owns the HUD layout + brush values, the painter owns the canvas pixels. // // The brush values are also mirrored into an in-memory snapshot (_carry) on this persistent HUD, so // they survive the avatar respawn that a scene switch (lobby -> map) triggers: the new painter gets // the carried values instead of its prefab defaults. The save key is derived from the HUD's OWN // scene (a stable persistent scene), not the active scene - the latter now changes across the // additive lobby/map loads, which would split save and restore into different files. [Header("Persistence")] [Tooltip("Save the panel positions and their values when you move a panel or change a brush value, " + "and restore them on the next start. Off = the HUD opens at its authored layout / painter " + "defaults every session.")] public bool persistHud = true; [Tooltip("Save file identifier. Empty = derived from the HUD's own scene name. Set explicitly to keep " + "the layout stable across scene renames or to share one layout between scenes.")] public string hudSaveId = ""; private const int HudFileVersion = 1; // Auto-save bookkeeping. Panel drops save instantly; brush-value edits are debounced because sliders // fire every frame while dragged - we push the due time forward and write once they settle. No // teardown save, so persistence never depends on a clean quit. private const float HudSaveDebounce = 0.5f; private readonly HudValues _savedValues = new HudValues(); // mirror of what is currently on disk private readonly HudValues _lastSeenValues = new HudValues(); // painter values observed last frame (settle detection) private bool _hasSavedBaseline; private bool _hudSavePending; private float _hudSaveDueTime; // In-memory mirror of the brush values, kept on this persistent HUD so they survive the avatar // respawn a scene switch (lobby <-> map) triggers: the freshly spawned painter is seeded with these // instead of its prefab defaults. Single instance with mutable fields -> no per-frame allocation. private readonly HudValues _carry = new HudValues(); private bool _hasCarry; // Camo knobs (HalfLambert / brightness / specular dampen) live on the player's PlayerCamoLit material // instance, which only exists after the painter's Start. They are saved/restored with the brush values // above, but can't be WRITTEN until the material is ready - so a restored (disk) or carried (respawn) // value waits here and is applied in the LateUpdate ready-gate. _camoEverReady gates the carry/save so // the not-yet-ready 0 the getter returns never overwrites the material's authored value. private Vector3 _pendingCamo; private bool _hasPendingCamo; private bool _camoEverReady; // Painted-height knobs (occlusion threshold, normal intensity, parallax intensity) ride the exact same // material-ready deferral as the camo knobs above: they become writable only after the painter's Start, // so a restored/carried value waits here and is applied in the LateUpdate ready-gate (shared timing). private Vector3 _pendingHeight; private bool _hasPendingHeight; // Authored defaults for the runtime reset (= "as if the saved layout file had been deleted"): the // prefab's panel positions, captured before any saved layout is restored over them, and the painter's // prefab/material default brush values, captured before carry/restore mutate the live painter. Camo is // material-backed (readable only once IsReady), so it is filled in the LateUpdate ready-gate instead. private readonly Dictionary _authoredPanelPos = new Dictionary(); private bool _hasAuthoredLayout; private readonly HudValues _defaults = new HudValues(); private bool _hasDefaults; // non-camo defaults captured (first bind, before restore/carry) private bool _hasDefaultCamo; // camo defaults captured (first ready-gate, before any pending camo apply) private static string HudSaveDir => Path.Combine(Application.persistentDataPath, "MeshPaint"); private string HudSavePath() { // Derive from the HUD's OWN scene (a stable persistent scene), not the active scene: the latter // now changes across the additive lobby/map loads, so a key from it would split save and restore // into different files and silently lose the saved layout. string key = string.IsNullOrEmpty(hudSaveId) ? SanitizeHudKey(gameObject.scene.name + "_hud") : SanitizeHudKey(hudSaveId); return Path.Combine(HudSaveDir, key + ".hudlayout"); } private static string SanitizeHudKey(string s) { foreach (char c in Path.GetInvalidFileNameChars()) s = s.Replace(c, '_'); return s.Replace(' ', '_'); } [Serializable] private class HudState { public int version = HudFileVersion; public bool hasValues; // false = no painter was bound when saved -> restore positions only public bool hasCamoValues; // false = pre-camo save file -> keep the material's authored camo (no clobber) public bool hasHeightValues; // false = pre-height save file -> keep the material's authored height knobs (no clobber) public List panels = new List(); public HudValues values = new HudValues(); } [Serializable] private struct PanelPos { public string name; public Vector2 pos; // anchoredPosition (point-anchored panels -> fully determines placement) } // Mirrors exactly the painter fields the HUD widgets bind to (PaintHud.WirePanel). [Serializable] private class HudValues { public bool paintAlbedo, paintMetallicSmoothness, paintOcclusion, paintEmission, spray; public Color brushColor; public float metallic, smoothness, metallicSmoothnessOpacity, occlusion, occlusionOpacity; public Color emissionColor; public float emissionIntensity; public int brushSize; public int activeBrushIndex; public int sprayDots; public float sprayDotSizeMin, sprayDotSizeMax, sprayDotAlphaMin, sprayDotAlphaMax; public float camoHalfLambert, camoHalfLambertBrightness, camoSpecularDampen; // PlayerCamoLit material knobs public float occlusionThreshold, heightNormalIntensity, parallaxIntensity; // painted-height knobs (material-backed) } /// A draggable panel was dropped -> persist the layout immediately. private void OnPanelMoved() => SaveHudState(); /// Writes the current panel positions and bound painter values to disk. public void SaveHudState() { if (!persistHud || !_panel) return; try { var state = new HudState(); var panel = _panel.transform; foreach (var name in DraggablePanels) { if (FindDeep(panel, name) is RectTransform rt) state.panels.Add(new PanelPos { name = name, pos = rt.anchoredPosition }); } if (painter) { CopyValues(painter, state.values); state.hasValues = true; state.hasCamoValues = painter.IsReady; // only claim camo when it was actually readable state.hasHeightValues = painter.IsReady; // height knobs share the camo readiness } else if (_hasCarry) { // No painter bound at teardown (e.g. quit with no avatar) -> persist the last mirrored values. state.values = _carry; state.hasValues = true; state.hasCamoValues = _camoEverReady; // _carry holds valid camo only after a ready painter state.hasHeightValues = _camoEverReady; } Directory.CreateDirectory(HudSaveDir); File.WriteAllText(HudSavePath(), JsonUtility.ToJson(state, true)); // Refresh the on-disk baseline so the debounce does not immediately re-save unchanged values. if (painter) CopyValues(painter, _savedValues); _hasSavedBaseline = painter; _hudSavePending = false; } catch (Exception e) { Debug.LogWarning($"[PaintHud] Saving HUD state failed: {e.Message}", this); } } /// /// Restores the saved panel positions and brush values. Values are written into the painter (the /// source of truth the widgets read) and then pushed to every widget via , /// reusing the exact same painter -> HUD path the pipette pick uses. Positions are applied last. /// public void RestoreHudState() { if (!persistHud || !_panel) return; var state = LoadHudState(); if (state == null) return; if (painter && state.hasValues && state.values != null) { ApplyValues(painter, state.values); SyncFromPainter(); } // Camo is material-backed and can't be written until the painter is ready -> stash the saved values // for the LateUpdate ready-gate. hasCamoValues guards pre-camo save files: without it we would apply // a 0 and clobber the material's authored camo. if (state.hasCamoValues && state.values != null) { _pendingCamo = new Vector3(state.values.camoHalfLambert, state.values.camoHalfLambertBrightness, state.values.camoSpecularDampen); _hasPendingCamo = true; } // Same deferral for the painted-height knobs; hasHeightValues guards pre-height save files so they // keep the material's authored defaults instead of being slammed to a 0 threshold/flat relief. if (state.hasHeightValues && state.values != null) { _pendingHeight = new Vector3(state.values.occlusionThreshold, state.values.heightNormalIntensity, state.values.parallaxIntensity); _hasPendingHeight = true; } // Point-anchored panels: anchoredPosition is resolution-independent under the canvas' // ScaleWithScreenSize scaler, so the raw saved value places them identically next session. var root = _panel.transform; if (state.panels != null) foreach (var p in state.panels) if (FindDeep(root, p.name) is RectTransform rt) rt.anchoredPosition = p.pos; } // --- In-session carry: brush values survive the avatar respawn a scene switch triggers --- private void LateUpdate() { if (painter) { // Camo knobs are material-backed and only readable/writable after the painter's Start built the // instance (binding ran earlier, in the avatar's OnNetworkSpawn). Apply the restored (disk) or // carried (respawn) values to the material once it is ready, then seed the sliders from it. Done // BEFORE the carry mirror + change detection below so the just-applied value is already the // baseline and never re-triggers a redundant auto-save. With nothing pending (first session, no // save file) the material keeps its authored camo and the sliders just reflect it. if (!_camoSynced && painter.IsReady) { // Capture the material's authored camo as the reset default BEFORE any pending (restored or // carried) camo is applied over it. Once per HUD: a later session/respawn keeps the very // first authored value, so a reset always returns to the genuine material default. if (!_hasDefaultCamo) { _defaults.camoHalfLambert = painter.CamoHalfLambert; _defaults.camoHalfLambertBrightness = painter.CamoHalfLambertBrightness; _defaults.camoSpecularDampen = painter.CamoSpecularDampen; _defaults.occlusionThreshold = painter.OcclusionThreshold; _defaults.heightNormalIntensity = painter.HeightNormalIntensity; _defaults.parallaxIntensity = painter.ParallaxIntensity; _hasDefaultCamo = true; } if (_hasPendingCamo) { painter.CamoHalfLambert = _pendingCamo.x; painter.CamoHalfLambertBrightness = _pendingCamo.y; painter.CamoSpecularDampen = _pendingCamo.z; _hasPendingCamo = false; } if (_hasPendingHeight) { painter.OcclusionThreshold = _pendingHeight.x; painter.HeightNormalIntensity = _pendingHeight.y; painter.ParallaxIntensity = _pendingHeight.z; _hasPendingHeight = false; } SyncCamoSliders(); _savedValues.camoHalfLambert = _lastSeenValues.camoHalfLambert = painter.CamoHalfLambert; _savedValues.camoHalfLambertBrightness = _lastSeenValues.camoHalfLambertBrightness = painter.CamoHalfLambertBrightness; _savedValues.camoSpecularDampen = _lastSeenValues.camoSpecularDampen = painter.CamoSpecularDampen; _savedValues.occlusionThreshold = _lastSeenValues.occlusionThreshold = painter.OcclusionThreshold; _savedValues.heightNormalIntensity = _lastSeenValues.heightNormalIntensity = painter.HeightNormalIntensity; _savedValues.parallaxIntensity = _lastSeenValues.parallaxIntensity = painter.ParallaxIntensity; _camoEverReady = true; _camoSynced = true; } // Mirror the live painter every frame; the next BindPainter (after a respawn) seeds the fresh // painter from this snapshot instead of its prefab defaults. Captured while the painter lives, // so it is immune to the destroy-pending timing of the despawn. CaptureCarry(); // Debounced auto-save: (re)arm the settle timer only on frames where a value actually CHANGED // since the last frame - not merely differs from disk. Sliders fire every frame while dragged, // so this keeps the deadline ahead of the drag and writes once the values stop moving. Comparing // against the on-disk baseline instead would push the deadline forward forever (the edit always // differs from disk until it is written), so the save would never fire on its own - it only // landed when something else, e.g. a panel drop, wrote the state directly. if (persistHud && _hasSavedBaseline && !ValuesEqual(painter, _lastSeenValues)) { CopyValues(painter, _lastSeenValues); if (!ValuesEqual(painter, _savedValues)) { _hudSavePending = true; _hudSaveDueTime = Time.unscaledTime + HudSaveDebounce; } } } if (_hudSavePending && Time.unscaledTime >= _hudSaveDueTime) SaveHudState(); } /// Marks the current brush values as the on-disk baseline so auto-save only fires on real edits. private void SnapshotSavedBaseline() { if (painter) { CopyValues(painter, _savedValues); CopyValues(painter, _lastSeenValues); } _hasSavedBaseline = painter; _hudSavePending = false; } /// True when every brush value on the painter still matches the snapshot last written to disk. private static bool ValuesEqual(MeshPainter p, HudValues v) { return p.paintAlbedo == v.paintAlbedo && p.paintMetallicSmoothness == v.paintMetallicSmoothness && p.paintOcclusion == v.paintOcclusion && p.paintEmission == v.paintEmission && p.spray == v.spray && p.brushColor == v.brushColor && p.metallic == v.metallic && p.smoothness == v.smoothness && p.metallicSmoothnessOpacity == v.metallicSmoothnessOpacity && p.occlusion == v.occlusion && p.occlusionOpacity == v.occlusionOpacity && p.emissionColor == v.emissionColor && p.emissionIntensity == v.emissionIntensity && p.brushSize == v.brushSize && p.activeBrushIndex == v.activeBrushIndex && p.sprayDots == v.sprayDots && p.sprayDotSizeMin == v.sprayDotSizeMin && p.sprayDotSizeMax == v.sprayDotSizeMax && p.sprayDotAlphaMin == v.sprayDotAlphaMin && p.sprayDotAlphaMax == v.sprayDotAlphaMax && p.CamoHalfLambert == v.camoHalfLambert && p.CamoHalfLambertBrightness == v.camoHalfLambertBrightness && p.CamoSpecularDampen == v.camoSpecularDampen && p.OcclusionThreshold == v.occlusionThreshold && p.HeightNormalIntensity == v.heightNormalIntensity && p.ParallaxIntensity == v.parallaxIntensity; } /// Snapshots the bound painter's brush values into the in-memory mirror. public void CaptureCarry() { if (!painter) return; CopyValues(painter, _carry); _hasCarry = true; } /// Writes the mirrored brush values onto the (freshly bound) painter and pushes them to the widgets. public void ApplyCarry() { if (!painter || !_hasCarry) return; ApplyValues(painter, _carry); SyncFromPainter(); // Camo is material-backed -> defer to the ready-gate using the carried (last live) values. Guarded // by _camoEverReady so a carry taken before any material was ready can't apply a stale 0. if (_camoEverReady) { _pendingCamo = new Vector3(_carry.camoHalfLambert, _carry.camoHalfLambertBrightness, _carry.camoSpecularDampen); _hasPendingCamo = true; _pendingHeight = new Vector3(_carry.occlusionThreshold, _carry.heightNormalIntensity, _carry.parallaxIntensity); _hasPendingHeight = true; } } private static void CopyValues(MeshPainter from, HudValues v) { v.paintAlbedo = from.paintAlbedo; v.paintMetallicSmoothness = from.paintMetallicSmoothness; v.paintOcclusion = from.paintOcclusion; v.paintEmission = from.paintEmission; v.spray = from.spray; v.brushColor = from.brushColor; v.metallic = from.metallic; v.smoothness = from.smoothness; v.metallicSmoothnessOpacity = from.metallicSmoothnessOpacity; v.occlusion = from.occlusion; v.occlusionOpacity = from.occlusionOpacity; v.emissionColor = from.emissionColor; v.emissionIntensity = from.emissionIntensity; v.brushSize = from.brushSize; v.activeBrushIndex = from.activeBrushIndex; v.sprayDots = from.sprayDots; v.sprayDotSizeMin = from.sprayDotSizeMin; v.sprayDotSizeMax = from.sprayDotSizeMax; v.sprayDotAlphaMin = from.sprayDotAlphaMin; v.sprayDotAlphaMax = from.sprayDotAlphaMax; // Camo is material-backed: the getters read 0 until the painter's material instance exists, so only // mirror them once ready - otherwise preserve the desired values already in the target (the value a // restore/carry put there, which the ready-gate still has to apply). if (from.IsReady) { v.camoHalfLambert = from.CamoHalfLambert; v.camoHalfLambertBrightness = from.CamoHalfLambertBrightness; v.camoSpecularDampen = from.CamoSpecularDampen; v.occlusionThreshold = from.OcclusionThreshold; v.heightNormalIntensity = from.HeightNormalIntensity; v.parallaxIntensity = from.ParallaxIntensity; } } private static void ApplyValues(MeshPainter to, HudValues v) { to.paintAlbedo = v.paintAlbedo; to.paintMetallicSmoothness = v.paintMetallicSmoothness; to.paintOcclusion = v.paintOcclusion; to.paintEmission = v.paintEmission; to.spray = v.spray; to.brushColor = v.brushColor; to.metallic = v.metallic; to.smoothness = v.smoothness; to.metallicSmoothnessOpacity = v.metallicSmoothnessOpacity; to.occlusion = v.occlusion; to.occlusionOpacity = v.occlusionOpacity; to.emissionColor = v.emissionColor; to.emissionIntensity = v.emissionIntensity; to.brushSize = v.brushSize; to.activeBrushIndex = v.activeBrushIndex; to.sprayDots = v.sprayDots; to.sprayDotSizeMin = v.sprayDotSizeMin; to.sprayDotSizeMax = v.sprayDotSizeMax; to.sprayDotAlphaMin = v.sprayDotAlphaMin; to.sprayDotAlphaMax = v.sprayDotAlphaMax; // Camo + height setters are a no-op until the material exists; the guaranteed deferred apply runs // from the LateUpdate ready-gate via _pendingCamo/_pendingHeight. Writing here too keeps Copy/Apply/Equal symmetric. to.CamoHalfLambert = v.camoHalfLambert; to.CamoHalfLambertBrightness = v.camoHalfLambertBrightness; to.CamoSpecularDampen = v.camoSpecularDampen; to.OcclusionThreshold = v.occlusionThreshold; to.HeightNormalIntensity = v.heightNormalIntensity; to.ParallaxIntensity = v.parallaxIntensity; } private HudState LoadHudState() { string path = HudSavePath(); if (!File.Exists(path)) return null; try { var state = JsonUtility.FromJson(File.ReadAllText(path)); if (state == null || state.version != HudFileVersion) return null; return state; } catch (Exception e) { Debug.LogWarning($"[PaintHud] Loading HUD state failed: {e.Message}", this); return null; } } // --- Reset to authored defaults (drives the HUD's reset button) --- /// /// Snapshots the authored panel positions from the freshly instantiated layout, before any saved /// layout is restored over them. The runtime reset restores exactly these. Called once from Start /// (right after the layout prefab is instantiated); idempotent. /// public void CaptureAuthoredLayout() { if (_hasAuthoredLayout || !_panel) return; var root = _panel.transform; foreach (var name in DraggablePanels) if (FindDeep(root, name) is RectTransform rt) _authoredPanelPos[name] = rt.anchoredPosition; _hasAuthoredLayout = true; } /// /// Snapshots the painter's prefab-default brush values on the first bind, before carry/restore mutate /// the live painter. Non-camo only here (a freshly spawned painter holds its prefab defaults); the /// material-backed camo is not readable yet and is captured later in the LateUpdate ready-gate. /// private void CaptureDefaults() { if (_hasDefaults || !painter) return; CopyValues(painter, _defaults); // CopyValues skips camo until IsReady -> camo stays for the ready-gate _hasDefaults = true; } /// /// Resets the HUD to its authored defaults, exactly as if the saved layout file had been deleted: the /// panels jump back to their prefab positions and every brush value returns to the painter's prefab / /// material defaults. The save file is deleted too, so the reset also survives the next start. /// [ContextMenu("Reset HUD Layout And Values")] public void ResetHudToDefaults() { // Delete the saved layout so a restart opens at the authored defaults as well (the literal intent). ClearSavedHudState(); // Panels -> authored prefab positions. if (_panel && _hasAuthoredLayout) { var root = _panel.transform; foreach (var kv in _authoredPanelPos) if (FindDeep(root, kv.Key) is RectTransform rt) rt.anchoredPosition = kv.Value; } // Brush values -> painter prefab/material defaults, then push them to every widget. if (painter && _hasDefaults) { // Reset clicked before the ready-gate captured the camo defaults? keep the live camo rather than // slamming it to the not-yet-filled 0 (which would flatten the material's lighting). if (!_hasDefaultCamo && painter.IsReady) { _defaults.camoHalfLambert = painter.CamoHalfLambert; _defaults.camoHalfLambertBrightness = painter.CamoHalfLambertBrightness; _defaults.camoSpecularDampen = painter.CamoSpecularDampen; _defaults.occlusionThreshold = painter.OcclusionThreshold; _defaults.heightNormalIntensity = painter.HeightNormalIntensity; _defaults.parallaxIntensity = painter.ParallaxIntensity; } ApplyValues(painter, _defaults); SyncFromPainter(); SyncCamoSliders(); // ApplyValues wrote the camo + height knobs straight to the material (painter is ready at a // runtime reset), so drop any still-pending restored values the ready-gate would re-apply over the default. _hasPendingCamo = false; _hasPendingHeight = false; CaptureCarry(); // a later respawn now carries the defaults, not the pre-reset values SnapshotSavedBaseline(); // values match the (now deleted) save -> the reset itself triggers no auto-save } } /// Deletes the saved HUD layout (next start opens at the authored layout / defaults). Editor convenience. [ContextMenu("Clear Saved HUD Layout")] public void ClearSavedHudState() { try { string path = HudSavePath(); if (File.Exists(path)) File.Delete(path); Debug.Log($"[PaintHud] Cleared saved HUD layout: {path}", this); } catch (Exception e) { Debug.LogWarning($"[PaintHud] Clearing saved HUD layout failed: {e.Message}", this); } } }