From f22ca4765feb73ffc95a5fdcab2edc9b8b2f1cdd Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Sun, 28 Jun 2026 13:56:02 +0200 Subject: [PATCH] Persist HUD layout on drag-end instead of only on teardown HUD panel positions and brush values were written only from OnApplicationQuit/OnDestroy, which is not guaranteed in a build (Alt-F4, process kill, crash), so layout work was lost. DraggablePanel now exposes an IEndDragHandler callback that saves the layout the moment a panel is dropped. Brush-value changes are detected against a disk baseline in LateUpdate and flushed after a 0.5s debounce so per-frame slider drags do not write every frame. The teardown save path is removed. Co-Authored-By: Claude Opus 4.8 --- Assets/Scripts/DraggablePanel.cs | 13 +++- Assets/Scripts/PaintHud.Persistence.cs | 98 ++++++++++++++++++++------ Assets/Scripts/PaintHud.cs | 5 +- 3 files changed, 93 insertions(+), 23 deletions(-) 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)