Files
chameleon/Assets/Scripts/Components/UI/PaintHud/PaintHud.Persistence.cs
T
adminandClaude Opus 4.8 f586ef7a15 Simplify HUD layout save path to a fixed PaintHud.json
Drop the scene-derived hudSaveId/key mechanism and save the HUD layout to a
single PaintHud.json under persistentDataPath.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 14:52:22 +02:00

513 lines
23 KiB
C#

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 (panel drop = instant, brush edits = debounced), restored on the
// next start. Deliberately NO save on quit/teardown: standalone builds don't guarantee a clean
// shutdown (OnApplicationQuit is skipped on force-close/kill/crash), which silently lost the whole
// session's layout in the build. Small structured state -> human-readable JSON, independent of the
// painter's bulk pixel maps (MeshPainter.Persistence). The brush values are also mirrored in-memory
// (_carry) so they survive the avatar respawn a scene switch triggers.
[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;
private const int HudFileVersion = 1;
// Auto-save bookkeeping. Brush edits are debounced (sliders fire every frame while dragged): the due
// time is pushed forward and written once they settle.
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 seeding a freshly respawned painter (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 live on the material instance, which only exists after the painter's Start: a restored
// (disk) or carried (respawn) value waits here and is applied in the LateUpdate ready-gate.
// _camoEverReady gates carry/save so the not-yet-ready 0 never overwrites the authored value.
private Vector3 _pendingCamo;
private bool _hasPendingCamo;
private bool _camoEverReady;
// Painted-height knobs ride the exact same material-ready deferral as the camo knobs above.
private Vector3 _pendingHeight;
private bool _hasPendingHeight;
// Authored defaults for the runtime reset ("as if the save file had been deleted"): prefab panel
// positions + prefab/material default brush values, captured before restore/carry mutate them. Camo
// is material-backed (readable only once IsReady), so it is filled in the ready-gate instead.
private readonly Dictionary<string, Vector2> _authoredPanelPos = new Dictionary<string, Vector2>();
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 string HudSavePath()
{
return Path.Combine(Application.persistentDataPath, "PaintHud.json");
}
[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<PanelPos> panels = new List<PanelPos>();
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)
}
/// <summary>A draggable panel was dropped -> persist the layout immediately.</summary>
private void OnPanelMoved() => SaveHudState();
/// <summary>Writes the current panel positions and bound painter values to disk.</summary>
public void SaveHudState()
{
if (!persistHud || !_panel) return;
try
{
var state = new HudState();
var panel = _panel.transform;
foreach (var name in DraggablePanels)
{
if (GeometryUtil.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(Application.persistentDataPath);
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);
}
}
/// <summary>
/// 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 <see cref="SyncFromPainter"/>,
/// reusing the exact same painter -> HUD path the pipette pick uses. Positions are applied last.
/// </summary>
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 -> stash for the LateUpdate ready-gate. hasCamoValues guards pre-camo
// save files (applying their 0 would clobber the authored camo).
if (state.hasCamoValues && state.values != null)
{
_pendingCamo = new Vector3(state.values.camoHalfLambert,
state.values.camoHalfLambertBrightness,
state.values.camoSpecularDampen);
_hasPendingCamo = true;
}
// Same deferral + guard for the painted-height knobs.
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 (GeometryUtil.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)
{
// Apply restored/carried camo + height to the material once it is ready, then seed the
// sliders. Runs BEFORE the carry mirror + change detection below, so the just-applied value
// is already the baseline and never re-triggers a redundant auto-save.
if (!_camoSynced && painter.IsReady)
{
// Capture the authored camo as the reset default BEFORE any pending value is applied over
// it - once per HUD, so a reset always returns to the genuine material default.
if (!_hasDefaultCamo)
{
CaptureCamoDefaults();
_hasDefaultCamo = true;
}
if (_hasPendingCamo)
{
painter.CamoKnobs = _pendingCamo;
_hasPendingCamo = false;
}
if (_hasPendingHeight)
{
painter.HeightKnobs = _pendingHeight;
_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. Captured while the painter lives - immune to despawn timing.
CaptureCarry();
// Debounced auto-save: (re)arm the settle timer only on frames where a value actually CHANGED
// since the last frame - comparing against the on-disk baseline instead would push the
// deadline forward forever (an edit differs from disk until written), so it would never fire.
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();
}
/// <summary>Marks the current brush values as the on-disk baseline so auto-save only fires on real edits.</summary>
private void SnapshotSavedBaseline()
{
if (painter)
{
CopyValues(painter, _savedValues);
CopyValues(painter, _lastSeenValues);
}
_hasSavedBaseline = painter;
_hudSavePending = false;
}
/// <summary>True when every brush value on the painter still matches the snapshot last written to disk.</summary>
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;
}
/// <summary>Snapshots the bound painter's brush values into the in-memory mirror.</summary>
public void CaptureCarry()
{
if (!painter) return;
CopyValues(painter, _carry);
_hasCarry = true;
}
/// <summary>Writes the mirrored brush values onto the (freshly bound) painter and pushes them to the widgets.</summary>
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 getters read 0 until the material exists - only mirror once ready, otherwise preserve the
// desired values already in the target (a restore/carry 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<HudState>(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) ---
/// <summary>
/// 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.
/// </summary>
public void CaptureAuthoredLayout()
{
if (_hasAuthoredLayout || !_panel) return;
var root = _panel.transform;
foreach (var name in DraggablePanels)
if (GeometryUtil.FindDeep(root, name) is RectTransform rt)
_authoredPanelPos[name] = rt.anchoredPosition;
_hasAuthoredLayout = true;
}
/// <summary>
/// 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.
/// </summary>
private void CaptureDefaults()
{
if (_hasDefaults || !painter) return;
CopyValues(painter, _defaults); // CopyValues skips camo until IsReady -> camo stays for the ready-gate
_hasDefaults = true;
}
/// <summary>Snapshots the live camo + height knobs into the reset defaults (caller decides _hasDefaultCamo).</summary>
private void CaptureCamoDefaults()
{
_defaults.camoHalfLambert = painter.CamoHalfLambert;
_defaults.camoHalfLambertBrightness = painter.CamoHalfLambertBrightness;
_defaults.camoSpecularDampen = painter.CamoSpecularDampen;
_defaults.occlusionThreshold = painter.OcclusionThreshold;
_defaults.heightNormalIntensity = painter.HeightNormalIntensity;
_defaults.parallaxIntensity = painter.ParallaxIntensity;
}
/// <summary>
/// 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.
/// </summary>
[ContextMenu("Reset HUD Layout And Values")]
public void ResetHudToDefaults()
{
ClearSavedHudState();
if (_panel && _hasAuthoredLayout)
{
var root = _panel.transform;
foreach (var kv in _authoredPanelPos)
if (GeometryUtil.FindDeep(root, kv.Key) is RectTransform rt)
rt.anchoredPosition = kv.Value;
}
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)
CaptureCamoDefaults();
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
}
}
/// <summary>Deletes the saved HUD layout (next start opens at the authored layout / defaults). Editor convenience.</summary>
[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);
}
}
}