Surface the PlayerCamoLit material knobs (HalfLambert, HalfLambert brightness, specular dampen) as material-backed properties on MeshPainter, guarded by an IsReady gate since the HUD binds before the material instance exists. Wire the three HUD sliders to them and seed the sliders from the live material once the painter is ready. Persist the values into the same HUD JSON as the brush settings (with a hasCamoValues flag so pre-camo save files never clobber the authored material) and carry them across a scene-switch respawn. Replicate them owner-authoritatively via a NetworkVariable in PlayerPaintSync, exactly like emission intensity, so remotes and late joiners show the same camo. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
421 lines
19 KiB
C#
421 lines
19 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 - 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;
|
|
|
|
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 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
|
|
}
|
|
|
|
/// <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 (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
|
|
}
|
|
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
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <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 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;
|
|
}
|
|
|
|
// 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)
|
|
{
|
|
if (_hasPendingCamo)
|
|
{
|
|
painter.CamoHalfLambert = _pendingCamo.x;
|
|
painter.CamoHalfLambertBrightness = _pendingCamo.y;
|
|
painter.CamoSpecularDampen = _pendingCamo.z;
|
|
_hasPendingCamo = false;
|
|
}
|
|
SyncCamoSliders();
|
|
|
|
_savedValues.camoHalfLambert = _lastSeenValues.camoHalfLambert = painter.CamoHalfLambert;
|
|
_savedValues.camoHalfLambertBrightness = _lastSeenValues.camoHalfLambertBrightness = painter.CamoHalfLambertBrightness;
|
|
_savedValues.camoSpecularDampen = _lastSeenValues.camoSpecularDampen = painter.CamoSpecularDampen;
|
|
|
|
_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();
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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 setters are a no-op until the material exists; the guaranteed deferred apply runs from the
|
|
// LateUpdate ready-gate via _pendingCamo. Writing here too keeps Copy/Apply/Equal symmetric.
|
|
to.CamoHalfLambert = v.camoHalfLambert;
|
|
to.CamoHalfLambertBrightness = v.camoHalfLambertBrightness;
|
|
to.CamoSpecularDampen = v.camoSpecularDampen;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <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);
|
|
}
|
|
}
|
|
}
|