diff --git a/Assets/Scripts/DraggablePanel.cs b/Assets/Scripts/DraggablePanel.cs
index 0fde83e..481be5c 100644
--- a/Assets/Scripts/DraggablePanel.cs
+++ b/Assets/Scripts/DraggablePanel.cs
@@ -14,7 +14,7 @@ using UnityEngine.EventSystems;
/// corners in its parent, not from a fixed top-left assumption).
///
[RequireComponent(typeof(RectTransform))]
-public class DraggablePanel : MonoBehaviour, IPointerDownHandler, IBeginDragHandler, IDragHandler
+public class DraggablePanel : MonoBehaviour, IPointerDownHandler, IBeginDragHandler, IDragHandler, IEndDragHandler
{
[Tooltip("Panel moved by the drag. Empty = this object's RectTransform.")]
public RectTransform target;
@@ -22,6 +22,10 @@ public class DraggablePanel : MonoBehaviour, IPointerDownHandler, IBeginDragHand
[Tooltip("Panel pixels (canvas units) kept on screen on every edge so it can never be lost.")]
public float keepVisible = 48f;
+ // Raised once when a real panel drag ends (not for gestures that began on a widget). The HUD uses
+ // this to persist the layout the moment a panel is dropped. Optional - left null by other users.
+ public System.Action Moved;
+
private RectTransform _area; // the panel's parent; pointer positions are tracked in its local space
private Vector2 _grabOffset;
private bool _dragging;
@@ -66,6 +70,13 @@ public class DraggablePanel : MonoBehaviour, IPointerDownHandler, IBeginDragHand
target.anchoredPosition = Clamp(local + _grabOffset);
}
+ public void OnEndDrag(PointerEventData eventData)
+ {
+ if (!_dragging) return; // gesture began on a widget -> not our drag, nothing moved
+ _dragging = false;
+ Moved?.Invoke();
+ }
+
// True when the press started on an interactive widget inside the panel (which then owns the gesture).
private bool StartedOnWidget(PointerEventData eventData)
{
diff --git a/Assets/Scripts/PaintHud.Persistence.cs b/Assets/Scripts/PaintHud.Persistence.cs
index e4a5d15..e03bb50 100644
--- a/Assets/Scripts/PaintHud.Persistence.cs
+++ b/Assets/Scripts/PaintHud.Persistence.cs
@@ -7,10 +7,14 @@ public partial class PaintHud
{
// --- Persistence: panel positions + the widget-bound painter values ---
//
- // Saved on quit / play-stop and restored on the next start, so the HUD opens where you left it
- // with the brush settings you last used. 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
+ // 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
@@ -20,8 +24,9 @@ public partial class PaintHud
// additive lobby/map loads, which would split save and restore into different files.
[Header("Persistence")]
- [Tooltip("Save the panel positions and their values on quit / play-stop and restore them on the " +
- "next start. Off = the HUD opens at its authored layout / painter defaults every session.")]
+ [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 " +
@@ -29,7 +34,15 @@ public partial class PaintHud
public string hudSaveId = "";
private const int HudFileVersion = 1;
- private bool _hudSavedOnTeardown; // guards a double save across OnApplicationQuit + OnDestroy
+
+ // 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 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
@@ -87,15 +100,8 @@ public partial class PaintHud
public float sprayDotSizeMin, sprayDotSizeMax, sprayDotAlphaMin, sprayDotAlphaMax;
}
- private void OnApplicationQuit() => SaveHudOnTeardown();
-
- /// Saves once during teardown (OnApplicationQuit and OnDestroy both call this; only the first writes).
- private void SaveHudOnTeardown()
- {
- if (_hudSavedOnTeardown) return;
- _hudSavedOnTeardown = true;
- SaveHudState();
- }
+ /// 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()
@@ -127,6 +133,11 @@ public partial class PaintHud
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)
{
@@ -165,10 +176,57 @@ public partial class PaintHud
private void LateUpdate()
{
- // 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.
- if (painter) CaptureCarry();
+ if (painter)
+ {
+ // 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();
+
+ // A brush-value edit differs from what is on disk -> schedule a coalesced save. Sliders fire
+ // every frame while dragged, so we keep pushing the due time forward and write once they settle.
+ if (persistHud && _hasSavedBaseline && !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);
+ _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;
}
/// Snapshots the bound painter's brush values into the in-memory mirror.
diff --git a/Assets/Scripts/PaintHud.cs b/Assets/Scripts/PaintHud.cs
index 1bae6ac..f35311b 100644
--- a/Assets/Scripts/PaintHud.cs
+++ b/Assets/Scripts/PaintHud.cs
@@ -162,6 +162,7 @@ public partial class PaintHud : MonoBehaviour
if (_hasCarry) ApplyCarry();
else RestoreHudState();
CaptureCarry(); // seed/refresh the mirror from the now-current painter
+ SnapshotSavedBaseline(); // values now match disk -> auto-save only fires on a real edit
UpdateButtonStates(); // enable Save now that a painter is bound
}
@@ -169,7 +170,6 @@ public partial class PaintHud : MonoBehaviour
private void OnDestroy()
{
if (painter) painter.Picked -= SyncFromPainter;
- SaveHudOnTeardown();
}
private void OnModeChanged(GameMode mode)
@@ -409,13 +409,14 @@ public partial class PaintHud : MonoBehaviour
/// from any of its non-interactive surface (header, padding, labels, background); the component
/// ignores presses that start on a widget (toggle, slider, colour picker).
///
- private static void MakeDraggable(Transform root, string panelName)
+ private void MakeDraggable(Transform root, string panelName)
{
var panel = FindDeep(root, panelName) as RectTransform;
if (!panel) return;
var drag = panel.GetComponent();
if (!drag) drag = panel.gameObject.AddComponent();
drag.target = panel;
+ drag.Moved = OnPanelMoved; // persist the layout the moment a panel is dropped
}
private void EnsureFonts(Transform panel)