Reorganize scripts into subfolders by responsibility

Move scripts under Assets/Scripts/ into Components, GameMode, Helper,
Network, Enum, Struct and ScriptableObject folders. Pure file moves
(plus folder .meta files); no code changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-07-02 15:02:15 +02:00
1 parent 382bfb36df
commit e23fae0c50
126 files changed
+112

No files matched your search

@@ -0,0 +1,120 @@
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
/// <summary>
/// Makes a UI panel draggable. Attach to the panel itself: it follows the pointer from any of the panel's
/// non-interactive surface (title bar, padding, labels, background) but ignores presses that begin on a
/// widget - a <see cref="Selectable"/> (toggle, slider, ...) or a <see cref="UiPointerSurface"/> (the
/// colour-picker field) - so those keep their own click/drag behaviour. It keeps a slice of the panel on
/// screen and raises it above its siblings while interacting.
///
/// Works under any canvas scale (it tracks the pointer in the drag area's local space and applies the
/// movement delta) and under any anchor/pivot (the on-screen clamp is derived from the panel's real
/// corners in its parent, not from a fixed top-left assumption).
/// </summary>
[RequireComponent(typeof(RectTransform))]
public class DraggablePanel : MonoBehaviour, IPointerDownHandler, IBeginDragHandler, IDragHandler, IEndDragHandler
{
[Tooltip("Panel moved by the drag. Empty = this object's RectTransform.")]
public RectTransform target;
[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;
private void Awake() => Resolve();
private void Resolve()
{
if (!target) target = transform as RectTransform;
if (target) _area = target.parent as RectTransform;
}
public void OnPointerDown(PointerEventData eventData)
{
// Raise the panel when pressing its own surface. (A press that lands on a widget is consumed by
// that widget's own handler and never reaches us, so this rarely needs the guard - but it is cheap.)
if (!StartedOnWidget(eventData)) BringToFront();
}
public void OnBeginDrag(PointerEventData eventData)
{
_dragging = false;
if (!target) Resolve();
if (!target || !_area) return;
if (StartedOnWidget(eventData)) return; // a toggle/slider/picker owns this gesture, not the panel
_dragging = true;
BringToFront();
// Capture grab offset; the constant frame difference between the area pivot and the panel anchor
// cancels out because we only ever apply the pointer delta on top of this offset.
if (RectTransformUtility.ScreenPointToLocalPointInRectangle(
_area, eventData.position, eventData.pressEventCamera, out var local))
_grabOffset = target.anchoredPosition - local;
}
public void OnDrag(PointerEventData eventData)
{
if (!_dragging || !target || !_area) return;
if (!RectTransformUtility.ScreenPointToLocalPointInRectangle(
_area, eventData.position, eventData.pressEventCamera, out var local))
return;
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)
{
var hit = eventData.pointerPressRaycast.gameObject;
for (var t = hit ? hit.transform : null; t && t != target; t = t.parent)
if (t.GetComponent<Selectable>() || t.GetComponent<UiPointerSurface>())
return true;
return false;
}
private void BringToFront()
{
if (target) target.SetAsLastSibling();
}
// Anchor/pivot-agnostic clamp: keep at least `keepVisible` px of the panel inside the parent on every
// edge, computed from the panel's real corners so it is correct wherever the panel is anchored.
private Vector2 Clamp(Vector2 pos)
{
if (!_area) return pos;
Rect area = _area.rect;
Rect size = target.rect;
Vector2 pivot = target.pivot;
// Anchor reference point in the area's local space (panels are point-anchored: anchorMin == anchorMax).
Vector2 anchorRef = new Vector2(
area.xMin + target.anchorMin.x * area.width,
area.yMin + target.anchorMin.y * area.height);
Vector2 pivotToCorner = new Vector2(pivot.x * size.width, pivot.y * size.height);
// Bottom-left corner of the panel for this anchoredPosition.
Vector2 corner = anchorRef + pos - pivotToCorner;
float keepX = Mathf.Min(keepVisible, size.width);
float keepY = Mathf.Min(keepVisible, size.height);
corner.x = Mathf.Clamp(corner.x, area.xMin + keepX - size.width, area.xMax - keepX);
corner.y = Mathf.Clamp(corner.y, area.yMin + keepY - size.height, area.yMax - keepY);
return corner + pivotToCorner - anchorRef;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 14b82ffde5db5c547b5f454ac2791815
@@ -0,0 +1,132 @@
using System;
using UnityEngine;
using UnityEngine.UI;
/// <summary>
/// Compact runtime HSV colour picker: a saturation/value square, a hue bar and a live swatch. The
/// square is a pure-hue base with two static gradient overlays on top (white-out -> saturation,
/// black-out -> value), so only the base tint changes per hue - no per-frame texture rebuilds. Hue is
/// never lost while dragging the square (the square only edits S/V, the bar only edits H). Alpha is
/// edited separately by a standalone slider in the HUD, not by this picker; <see cref="SetColor"/>
/// still stores the incoming alpha so <see cref="Value"/> round-trips it.
///
/// The child structure (stable names) comes from the baked HUD prefab. <see cref="Rebind"/>
/// (re)assigns the runtime gradient sprites (which a prefab cannot store) and (re)hooks the drag
/// relays. A leftover alpha bar from a pre-split prefab is hidden by <see cref="Rebind"/>, so no
/// re-bake is required.
/// </summary>
public class HsvColorPicker : MonoBehaviour
{
/// <summary>Raised with the new colour (RGB; alpha is preserved, not edited here) when the user moves
/// the square or hue bar.</summary>
public event Action<Color> Changed;
private float _h, _s, _v, _a = 1f;
private Image _hueBase; // pure-hue background of the SV square
private RectTransform _svHandle;
private RectTransform _hueHandle;
private Image _swatch;
/// <summary>Current colour (HSV -> RGB, with the preserved alpha).</summary>
public Color Value
{
get
{
Color c = Color.HSVToRGB(_h, _s, _v);
c.a = _a;
return c;
}
}
/// <summary>Shows <paramref name="c"/> without raising <see cref="Changed"/> (external set).</summary>
public void SetColor(Color c)
{
Color.RGBToHSV(c, out _h, out _s, out _v);
_a = c.a;
Refresh();
}
/// <summary>
/// Re-finds the child widgets, (re)assigns the runtime gradient sprites and (re)hooks the drag
/// relays. Idempotent (unsubscribe-then-subscribe), so it is safe whether the structure was just
/// built in code or instantiated from a prefab. Call before <see cref="SetColor"/>.
/// </summary>
public void Rebind()
{
var sv = transform.Find("SV");
if (sv)
{
_hueBase = sv.GetComponent<Image>();
var sat = sv.Find("SatOverlay");
if (sat && sat.TryGetComponent(out Image satImg)) satImg.sprite = PaintHud.SaturationSprite();
var val = sv.Find("ValOverlay");
if (val && val.TryGetComponent(out Image valImg)) valImg.sprite = PaintHud.ValueSprite();
_svHandle = sv.Find("Handle") as RectTransform;
Rehook(sv, OnSvPicked);
}
var hue = transform.Find("Hue") as RectTransform;
if (hue)
{
if (hue.TryGetComponent(out Image hueImg)) hueImg.sprite = PaintHud.HueSprite();
_hueHandle = hue.Find("Handle") as RectTransform;
Rehook(hue, OnHuePicked);
}
// Alpha now lives on a standalone slider (PaintHud), not in the picker. Hide any alpha bar a
// pre-split prefab still carries so it does not show as a dead, non-functional strip.
var leftoverAlpha = transform.Find("Alpha");
if (leftoverAlpha) leftoverAlpha.gameObject.SetActive(false);
var swatch = transform.Find("Swatch");
if (swatch) _swatch = swatch.GetComponent<Image>();
Refresh();
}
// Idempotently hooks the handler to the surface's drag relay (unsubscribe-then-subscribe).
private static void Rehook(Component surface, Action<Vector2> handler)
{
if (surface.TryGetComponent(out UiPointerSurface pointer))
{
pointer.Picked -= handler;
pointer.Picked += handler;
}
}
private void OnSvPicked(Vector2 p)
{
_s = p.x;
_v = p.y;
Refresh();
Changed?.Invoke(Value);
}
private void OnHuePicked(Vector2 p)
{
_h = p.x;
Refresh();
Changed?.Invoke(Value);
}
private void Refresh()
{
if (_hueBase) _hueBase.color = Color.HSVToRGB(_h, 1f, 1f);
if (_svHandle)
{
_svHandle.anchorMin = _svHandle.anchorMax = new Vector2(_s, _v);
_svHandle.anchoredPosition = Vector2.zero;
}
if (_hueHandle)
{
_hueHandle.anchorMin = _hueHandle.anchorMax = new Vector2(_h, 0.5f);
_hueHandle.anchoredPosition = Vector2.zero;
}
if (_swatch) _swatch.color = Color.HSVToRGB(_h, _s, _v);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2a35dda49401e4045ba2165e15116a5f
@@ -0,0 +1,37 @@
using TMPro;
using UnityEngine;
using UnityEngine.UI;
/// Binds a UI Slider (and optional value label) to the global MouseSensitivity
/// multiplier. The slider's range is authored in the inspector (0.1 - 4.0);
/// this only reads/writes the value so scene-side range edits are respected.
public class MouseSensitivitySlider : MonoBehaviour
{
[SerializeField] private Slider slider;
[Tooltip("Optional TMP label showing the current multiplier (e.g. \"1,00\").")]
[SerializeField] private TMP_Text valueLabel;
void OnEnable()
{
if (!slider) return;
slider.SetValueWithoutNotify(MouseSensitivity.Multiplier);
UpdateLabel(MouseSensitivity.Multiplier);
slider.onValueChanged.AddListener(OnSliderChanged);
}
void OnDisable()
{
if (slider) slider.onValueChanged.RemoveListener(OnSliderChanged);
}
void OnSliderChanged(float value)
{
MouseSensitivity.Multiplier = value;
UpdateLabel(value);
}
void UpdateLabel(float value)
{
if (valueLabel) valueLabel.text = value.ToString("0.00");
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: fc6da376d2cfebe47beeb9b6012e76dd
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d09de72a6314d2d40bd136e616623b80
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,307 @@
using System;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
/// <summary>
/// Binder for the "Paintings" library panel. The panel's visual layout (Panel_Paintings, the scrollable
/// list, an entry template, and the Save / Confirm dialogs) is authored inside the PaintHudPanel layout
/// prefab - this code only finds those objects by their stable names and wires the behaviour, exactly like
/// <see cref="WirePanel"/> does for the brush widgets. The list is populated by cloning the authored
/// Lib_EntryTemplate per saved slot. Save opens a name dialog (an existing name is overwritten, with a
/// warning shown while typing); Load applies the selected slot immediately; Delete goes through a
/// confirmation dialog. Loading replaces the current painting and, when networked, re-broadcasts the
/// loaded state to every other peer.
/// </summary>
public partial class PaintHud
{
// Stable widget names, matching the objects authored in the PaintHudPanel prefab.
private const string LibListContent = "Lib_ListContent";
private const string LibEntryTemplate = "Lib_EntryTemplate";
private const string LibSaveButton = "Lib_Save";
private const string LibLoadButton = "Lib_Load";
private const string LibDeleteButton = "Lib_Delete";
private const string LibResetButton = "Lib_ResetHud";
private const string LibSaveDialog = "Lib_SaveDialog";
private const string LibNameInput = "Lib_NameInput";
private const string LibSaveInfo = "Lib_SaveInfo";
private const string LibSaveOk = "Lib_SaveOk";
private const string LibSaveCancel = "Lib_SaveCancel";
private const string LibConfirmDialog = "Lib_ConfirmDialog";
private const string LibConfirmText = "Lib_ConfirmText";
private const string LibConfirmOk = "Lib_ConfirmOk";
private const string LibConfirmCancel = "Lib_ConfirmCancel";
private bool _libraryBound;
private RectTransform _listContent;
private GameObject _entryTemplate;
private Button _saveButton, _loadButton, _deleteButton, _resetButton;
private string _selectedPainting;
// Save dialog
private GameObject _saveDialog;
private TMP_InputField _nameInput;
private TMP_Text _saveInfo;
private Button _saveOkButton;
// Confirmation dialog (reused for Load + Delete)
private GameObject _confirmDialog;
private TMP_Text _confirmText;
private Button _confirmButton;
private Action _confirmAction;
private struct EntryRef { public string name; public Image bg; }
private readonly List<EntryRef> _entries = new List<EntryRef>();
private static readonly Color EntryNormal = new Color(0.18f, 0.18f, 0.22f, 1f);
private static readonly Color EntrySelected = new Color(0.20f, 0.45f, 0.80f, 1f);
private static readonly Color OkDanger = new Color(0.62f, 0.26f, 0.26f, 1f);
// --- Bind (find authored objects, hook listeners) ---
/// <summary>
/// Binds the authored library panel under <paramref name="root"/> (the instantiated layout prefab).
/// Idempotent: binds exactly once. No-op if the prefab carries no library objects.
/// </summary>
private void BindLibrary(Transform root)
{
if (_libraryBound || !root) return;
_listContent = GeometryUtil.FindDeep(root, LibListContent) as RectTransform;
var template = GeometryUtil.FindDeep(root, LibEntryTemplate);
_entryTemplate = template ? template.gameObject : null;
if (!_listContent || !_entryTemplate) return; // panel not present in this layout
_entryTemplate.SetActive(false);
_saveButton = Find<Button>(root, LibSaveButton);
_loadButton = Find<Button>(root, LibLoadButton);
_deleteButton = Find<Button>(root, LibDeleteButton);
_resetButton = Find<Button>(root, LibResetButton); // optional: present only if authored in the layout
Hook(_saveButton, OnSaveClicked);
Hook(_loadButton, OnLoadClicked);
Hook(_deleteButton, OnDeleteClicked);
Hook(_resetButton, OnResetClicked);
var sd = GeometryUtil.FindDeep(root, LibSaveDialog);
_saveDialog = sd ? sd.gameObject : null;
_nameInput = Find<TMP_InputField>(root, LibNameInput);
_saveInfo = Find<TMP_Text>(root, LibSaveInfo);
_saveOkButton = Find<Button>(root, LibSaveOk);
Hook(_saveOkButton, ConfirmSave);
Hook(Find<Button>(root, LibSaveCancel), () => { if (_saveDialog) _saveDialog.SetActive(false); });
if (_nameInput) _nameInput.onValueChanged.AddListener(_ => ValidateSaveName());
var cd = GeometryUtil.FindDeep(root, LibConfirmDialog);
_confirmDialog = cd ? cd.gameObject : null;
_confirmText = Find<TMP_Text>(root, LibConfirmText);
_confirmButton = Find<Button>(root, LibConfirmOk);
Hook(_confirmButton, OnConfirmClicked);
Hook(Find<Button>(root, LibConfirmCancel), () => { if (_confirmDialog) _confirmDialog.SetActive(false); });
HideDialogs();
_libraryBound = true;
UpdateButtonStates();
}
private static void Hook(Button button, Action onClick)
{
if (!button || onClick == null) return;
button.onClick.RemoveAllListeners();
button.onClick.AddListener(() => onClick());
}
// --- List ---
private void RefreshList()
{
if (!_listContent || !_entryTemplate) return;
for (int i = _listContent.childCount - 1; i >= 0; i--)
{
var child = _listContent.GetChild(i).gameObject;
if (child != _entryTemplate) Destroy(child);
}
_entries.Clear();
var names = MeshPainter.ListSavedPaintings();
if (!string.IsNullOrEmpty(_selectedPainting) && !names.Contains(_selectedPainting))
_selectedPainting = null;
foreach (var name in names)
{
var go = Instantiate(_entryTemplate, _listContent);
go.name = "Lib_Entry";
go.SetActive(true);
var bg = go.GetComponent<Image>();
if (bg) bg.color = name == _selectedPainting ? EntrySelected : EntryNormal;
var label = go.GetComponentInChildren<TMP_Text>(true);
if (label) label.text = name;
var btn = go.GetComponent<Button>();
if (btn)
{
string captured = name;
Hook(btn, () => SelectPainting(captured));
}
_entries.Add(new EntryRef { name = name, bg = bg });
}
UpdateButtonStates();
}
private void SelectPainting(string name)
{
_selectedPainting = name;
RefreshHighlights();
UpdateButtonStates();
}
private void RefreshHighlights()
{
foreach (var e in _entries)
if (e.bg) e.bg.color = e.name == _selectedPainting ? EntrySelected : EntryNormal;
}
private void UpdateButtonStates()
{
bool hasPainter = painter;
bool hasSel = !string.IsNullOrEmpty(_selectedPainting);
if (_saveButton) _saveButton.interactable = hasPainter;
if (_loadButton) _loadButton.interactable = hasPainter && hasSel;
if (_deleteButton) _deleteButton.interactable = hasSel;
}
private void HideDialogs()
{
if (_saveDialog) _saveDialog.SetActive(false);
if (_confirmDialog) _confirmDialog.SetActive(false);
}
// --- Actions ---
private void OnSaveClicked()
{
if (!painter || !_saveDialog) return;
// Pre-fill the currently selected painting's name so "Save" defaults to overwriting it (empty when
// nothing is selected) and select that text so the first keystroke replaces it for a fresh name.
if (_nameInput) _nameInput.text = _selectedPainting ?? "";
if (_saveInfo) _saveInfo.text = "";
_saveDialog.SetActive(true);
_saveDialog.transform.SetAsLastSibling(); // a dragged panel may have moved itself in front; stay on top
ValidateSaveName();
if (_nameInput)
{
_nameInput.ActivateInputField();
StartCoroutine(SelectNameFieldNextFrame());
}
}
// ActivateInputField only takes focus on a later update and resets the caret, so selecting the text this
// frame would be cleared. Wait one frame, then select the whole pre-filled name - done explicitly here so
// it works regardless of the field's authored "On Focus Select All" toggle.
private System.Collections.IEnumerator SelectNameFieldNextFrame()
{
yield return null;
if (!_nameInput) yield break;
_nameInput.selectionStringAnchorPosition = _nameInput.text.Length;
_nameInput.selectionStringFocusPosition = 0;
_nameInput.ForceLabelUpdate();
}
private void ValidateSaveName()
{
string name = _nameInput ? _nameInput.text.Trim() : "";
bool empty = string.IsNullOrEmpty(name);
bool exists = !empty && MeshPainter.PaintExists(name);
// Overwrite is allowed -> the OK button stays enabled even when the name exists; the warning is
// purely informational so you know you are about to replace an existing slot.
if (_saveOkButton) _saveOkButton.interactable = !empty && painter;
if (_saveInfo) _saveInfo.text = exists ? "A painting with this name already exists and will be overwritten." : "";
}
private void ConfirmSave()
{
if (!painter || !_nameInput) return;
string name = _nameInput.text.Trim();
if (string.IsNullOrEmpty(name)) { ValidateSaveName(); return; }
if (painter.SavePaintAs(name, overwrite: true))
{
_selectedPainting = name;
if (_saveDialog) _saveDialog.SetActive(false);
RefreshList();
}
else if (_saveInfo)
{
_saveInfo.text = "Save failed.";
}
}
private void OnLoadClicked()
{
if (!painter || string.IsNullOrEmpty(_selectedPainting)) return;
DoLoad(_selectedPainting); // load immediately, no confirmation
}
private void OnDeleteClicked()
{
if (string.IsNullOrEmpty(_selectedPainting)) return;
string name = _selectedPainting;
ShowConfirm($"Delete \"{name}\"?", OkDanger, () => DoDelete(name));
}
/// <summary>Reset the panels + brush values to their authored defaults (confirmed; the dialog reuses the
/// library's confirm path). Falls back to an immediate reset if no confirm dialog is authored.</summary>
private void OnResetClicked()
{
if (_confirmDialog)
ShowConfirm("Reset the HUD panels and brush values to their defaults?", OkDanger, ResetHudToDefaults);
else
ResetHudToDefaults();
}
private void DoLoad(string name)
{
if (!painter || !painter.LoadPaintByName(name)) return;
// The load also restored the slot's material scalars onto the painter (emission + camo from v3,
// the painted-height knobs from v4) -> push them into the sliders so the HUD reflects the loaded
// look, exactly like a pipette pick does. SyncCamoSliders covers the camo + height sliders.
SyncFromPainter();
SyncCamoSliders();
// Re-sync the loaded baseline to every other peer (no-op offline / single-player). The restored
// emission/camo replicate on their own via PlayerPaintSync's owner-authoritative NetworkVariables.
var sync = painter.GetComponentInParent<PlayerPaintSync>();
if (sync) sync.BroadcastLoadedPaint();
}
private void DoDelete(string name)
{
MeshPainter.DeletePaint(name);
if (_selectedPainting == name) _selectedPainting = null;
RefreshList();
}
private void ShowConfirm(string message, Color okColor, Action onConfirm)
{
if (!_confirmDialog) return;
if (_confirmText) _confirmText.text = message;
_confirmAction = onConfirm;
if (_confirmButton && _confirmButton.targetGraphic is Image okImg) okImg.color = okColor;
_confirmDialog.SetActive(true);
_confirmDialog.transform.SetAsLastSibling(); // a dragged panel may have moved itself in front; stay on top
}
private void OnConfirmClicked()
{
var act = _confirmAction;
_confirmAction = null;
if (_confirmDialog) _confirmDialog.SetActive(false);
act?.Invoke();
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 059854c655f028c4ba5f9b4b0bccdbb6
@@ -0,0 +1,526 @@
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;
[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. 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 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) => MeshPainter.SanitizeFileKey(s);
[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(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 -> 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);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4fb3415c5348e5c49955e222fd43a55c
@@ -0,0 +1,632 @@
using System;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
#if ENABLE_INPUT_SYSTEM
using UnityEngine.InputSystem.UI;
#endif
/// <summary>
/// In-game paint UI. Shows its panels only while the active <see cref="GameMode"/> is
/// <see cref="PaintMode"/> (follows the manager's ModeChanged) and binds its widgets directly to the
/// live <see cref="MeshPainter"/> fields, so edits apply immediately.
///
/// The layout (independently draggable sub-panels per channel + a global brush card) comes from the
/// authored prefab (<see cref="layoutPrefab"/>). The BINDINGS live in code and are (re)applied at
/// runtime by <see cref="WirePanel"/> against stable widget names - the prefab stores only the
/// visual layout, never behaviour.
/// </summary>
[DisallowMultipleComponent]
public partial class PaintHud : MonoBehaviour
{
[Tooltip("Mode manager driving visibility. Empty = found automatically.")]
public GameModeManager modeManager;
[Tooltip("Painter the HUD edits. Empty = the manager's painter, else found automatically.")]
public MeshPainter painter;
[Tooltip("Authored panel layout. Instantiated and bound at startup.")]
public GameObject layoutPrefab;
// Stable widget names shared by the baked prefab and the binder.
private const string PanelGeneral = "Panel_General";
private const string PanelSpray = "Panel_Spray";
private const string PanelAlbedo = "Panel_Albedo";
private const string PanelMetallicSmoothness = "Panel_MetallicSmoothness";
private const string PanelOcclusion = "Panel_Occlusion";
private const string PanelEmission = "Panel_Emission";
private const string PanelBrushSize = "Panel_BrushSize";
private const string PanelPaintings = "Panel_Paintings";
private const string ToggleAlbedo = "Toggle_Albedo";
private const string ToggleMetallicSmoothness = "Toggle_MetallicSmoothness";
private const string ToggleOcclusion = "Toggle_Occlusion";
private const string ToggleEmission = "Toggle_Emission";
private const string ToggleSpray = "Toggle_Spray";
private const string TogglePipette = "Toggle_Pipette";
private const string PickerAlbedo = "Picker_Albedo";
private const string PickerEmission = "Picker_Emission";
private const string SliderAlbedoAlpha = "Slider_AlbedoAlpha";
private const string SliderEmissionAlpha = "Slider_EmissionAlpha";
private const string SliderMetallic = "Slider_Metallic";
private const string SliderSmoothness = "Slider_Smoothness";
private const string SliderMetallicSmoothnessAlpha = "Slider_MetallicSmoothnessAlpha";
private const string SliderOcclusion = "Slider_Occlusion";
private const string SliderOcclusionAlpha = "Slider_OcclusionAlpha";
private const string SliderEmissionIntensity = "Slider_EmissionIntensity";
private const string SliderBrushSize = "Slider_BrushSize";
private const string SliderSprayDots = "Slider_SprayDots";
private const string SliderSprayDotSizeMin = "Slider_SprayDotSizeMin";
private const string SliderSprayDotSizeMax = "Slider_SprayDotSizeMax";
private const string SliderSprayDotAlphaMin = "Slider_SprayDotAlphaMin";
private const string SliderSprayDotAlphaMax = "Slider_SprayDotAlphaMax";
private const string SliderHalfLambert = "Slider_HalfLambert";
private const string SliderHalfLambertBrightness = "Slider_HalfLambertBrightness";
private const string SliderSpecularDamp = "Slider_SpecularDamp";
private const string SliderOcclusionThreshold = "Slider_OcclusionThreshold";
private const string SliderNormalIntensity = "Slider_NormalIntensity";
private const string SliderParallaxIntensity = "Slider_ParallaxIntensity";
private const string MaskSelector = "MaskSelector";
private const string MaskSwatchPrefix = "MaskSwatch_";
// Panels made draggable at runtime - grabbable from any non-widget surface (both prefab and code-gen paths).
private static readonly string[] DraggablePanels =
{
PanelAlbedo,
PanelMetallicSmoothness,
PanelOcclusion,
PanelEmission,
PanelBrushSize,
PanelSpray,
PanelGeneral,
PanelPaintings
};
private GameObject _panel;
private Font _font;
private bool _camoSynced; // camo sliders are material-backed -> seeded once the painter's material instance exists
private void Awake()
{
if (!modeManager) modeManager = FindFirstObjectByType<GameModeManager>();
}
private void OnEnable()
{
if (modeManager)
{
modeManager.ModeChanged += OnModeChanged;
modeManager.PainterChanged += OnPainterChanged;
}
}
private void OnDisable()
{
if (modeManager)
{
modeManager.ModeChanged -= OnModeChanged;
modeManager.PainterChanged -= OnPainterChanged;
}
}
private void Start()
{
_font = LoadFont();
EnsureEventSystem();
EnsureCanvas();
if (layoutPrefab)
{
var go = Instantiate(layoutPrefab, transform);
go.name = layoutPrefab.name; // strip the "(Clone)" suffix
_panel = go;
}
// Snapshot the authored panel positions now, while the layout is untouched - before RestoreHudState
// (in BindPainter) overwrites them. The reset button restores exactly these.
CaptureAuthoredLayout();
// The paint library now lives inside the authored layout prefab; bind its widgets like the brush panel.
BindLibrary(_panel ? _panel.transform : null);
// Bind as soon as a painter exists. With networked spawn-on-connect there is none at scene
// start; PainterChanged then wires the widgets once the local avatar spawns.
var p = painter;
if (!p) p = modeManager ? modeManager.Painter : null;
if (!p) p = FindFirstObjectByType<MeshPainter>();
if (p) BindPainter(p);
// Set initial visibility (the manager's first SwitchTo may have fired before this Start).
OnModeChanged(modeManager ? modeManager.ActiveMode : null);
}
private void OnPainterChanged(MeshPainter newPainter)
{
if (newPainter) BindPainter(newPainter);
}
/// <summary>
/// (Re)binds the HUD's widgets to a live painter. Called from Start when one already exists
/// (non-networked scene) or from the manager's PainterChanged event when the local networked
/// avatar spawns. Binds exactly once per painter: re-binding the same painter is a no-op, so
/// the deferred network path and the immediate scene path never double-add widget listeners.
/// </summary>
private void BindPainter(MeshPainter newPainter)
{
if (!newPainter || newPainter == painter || !_panel) return;
UnhookPainterEvents();
painter = newPainter;
_camoSynced = false; // re-seed the camo sliders once this painter's material instance is ready
WirePanel(_panel.transform);
// Re-sync every widget when the pipette adopts a sampled material (painter -> HUD).
painter.Picked += SyncFromPainter;
// Re-sync the brush-size slider when Shift+wheel resizes the brush in-world (painter -> HUD).
painter.BrushSizeChanged += SyncBrushSize;
// Snapshot the prefab-default brush values before carry/restore mutate them (the reset button
// returns to exactly these). Once per HUD.
CaptureDefaults();
// First bind this session: restore from disk. A later bind is an avatar respawn -> carry the
// in-session values onto the fresh painter instead.
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
}
private void OnDestroy() => UnhookPainterEvents();
private void UnhookPainterEvents()
{
if (!painter) return;
painter.Picked -= SyncFromPainter;
painter.BrushSizeChanged -= SyncBrushSize;
}
private void OnModeChanged(GameMode mode)
{
// Require a bound painter too: pre-spawn the manager sits in a player-less Paint mode, but there
// is nothing to paint yet - keep the HUD hidden until a painter is bound.
bool paint = mode is PaintMode && painter;
if (_panel) _panel.SetActive(paint); // toggles the brush panels and the library panel together
if (paint)
{
if (_libraryBound) RefreshList(); // pick up slots saved/changed since last shown
}
else HideDialogs();
}
private static Font LoadFont()
{
var f = Resources.GetBuiltinResource<Font>("LegacyRuntime.ttf");
if (!f) f = Resources.GetBuiltinResource<Font>("Arial.ttf");
return f;
}
// --- Canvas ---
private void EnsureCanvas()
{
var canvas = GetComponent<Canvas>();
if (!canvas) canvas = gameObject.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 100;
var scaler = GetComponent<CanvasScaler>();
if (!scaler) scaler = gameObject.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920f, 1080f);
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.Expand;
if (!GetComponent<GraphicRaycaster>()) gameObject.AddComponent<GraphicRaycaster>();
}
private void EnsureEventSystem()
{
if (FindFirstObjectByType<EventSystem>()) return;
var es = new GameObject("EventSystem", typeof(EventSystem));
#if ENABLE_INPUT_SYSTEM
var module = es.AddComponent<InputSystemUIInputModule>();
if (!module.actionsAsset) module.AssignDefaultActions();
#else
es.AddComponent<StandaloneInputModule>();
#endif
}
// --- Wiring (behaviour; shared by the code-gen and prefab paths) ---
private void WirePanel(Transform panel)
{
EnsureFonts(panel); // builtin font references may not survive prefab serialization
WireToggle(panel, ToggleAlbedo, painter && painter.paintAlbedo, v => { if (painter) painter.paintAlbedo = v; });
WireToggle(panel, ToggleMetallicSmoothness, painter && painter.paintMetallicSmoothness, v => { if (painter) painter.paintMetallicSmoothness = v; });
WireToggle(panel, ToggleOcclusion, painter && painter.paintOcclusion, v => { if (painter) painter.paintOcclusion = v; });
WireToggle(panel, ToggleEmission, painter && painter.paintEmission, v => { if (painter) painter.paintEmission = v; });
WireToggle(panel, ToggleSpray, painter && painter.spray, v => { if (painter) painter.spray = v; });
WireToggle(panel, TogglePipette, painter && painter.pipette, v => { if (painter) painter.pipette = v; });
WirePanelFade(panel, PanelAlbedo, ToggleAlbedo);
WirePanelFade(panel, PanelMetallicSmoothness, ToggleMetallicSmoothness);
WirePanelFade(panel, PanelOcclusion, ToggleOcclusion);
WirePanelFade(panel, PanelEmission, ToggleEmission);
WirePanelFade(panel, PanelSpray, ToggleSpray);
// Picker drives only RGB (keeping the painter's current alpha); a standalone slider drives alpha.
WirePicker(panel, PickerAlbedo, painter ? painter.brushColor : Color.red,
c => { if (painter) painter.brushColor = WithRgb(painter.brushColor, c); });
WireSlider(panel, SliderAlbedoAlpha, painter ? painter.brushColor.a : .6f,
v => { if (painter) painter.brushColor = WithAlpha(painter.brushColor, v); });
WireSlider(panel, SliderMetallic, painter ? painter.metallic : 0f, v => { if (painter) painter.metallic = v; });
WireSlider(panel, SliderSmoothness, painter ? painter.smoothness : 0.5f, v => { if (painter) painter.smoothness = v; });
WireSlider(panel, SliderMetallicSmoothnessAlpha, painter ? painter.metallicSmoothnessOpacity : .6f, v => { if (painter) painter.metallicSmoothnessOpacity = v; });
WireSlider(panel, SliderOcclusion, painter ? painter.occlusion : 0.5f, v => { if (painter) painter.occlusion = v; });
WireSlider(panel, SliderOcclusionAlpha, painter ? painter.occlusionOpacity : .6f, v => { if (painter) painter.occlusionOpacity = v; });
WirePicker(panel, PickerEmission, painter ? painter.emissionColor : Color.black,
c => { if (painter) painter.emissionColor = WithRgb(painter.emissionColor, c); });
WireSlider(panel, SliderEmissionIntensity, painter ? painter.emissionIntensity : 1f, v => { if (painter) painter.emissionIntensity = v; });
WireSlider(panel, SliderEmissionAlpha, painter ? painter.emissionColor.a : .6f,
v => { if (painter) painter.emissionColor = WithAlpha(painter.emissionColor, v); });
WireSlider(panel, SliderBrushSize, painter ? painter.brushSize : 16f, v => { if (painter) painter.brushSize = Mathf.RoundToInt(v); });
WireMaskSelector(panel);
WireSlider(panel, SliderSprayDots, painter ? painter.sprayDots : 24f, v => { if (painter) painter.sprayDots = Mathf.RoundToInt(v); });
WireSlider(panel, SliderSprayDotSizeMin, painter ? painter.sprayDotSizeMin : .05f, v => { if (painter) painter.sprayDotSizeMin = v; });
WireSlider(panel, SliderSprayDotSizeMax, painter ? painter.sprayDotSizeMax : .18f, v => { if (painter) painter.sprayDotSizeMax = v; });
WireSlider(panel, SliderSprayDotAlphaMin, painter ? painter.sprayDotAlphaMin : .2f, v => { if (painter) painter.sprayDotAlphaMin = v; });
WireSlider(panel, SliderSprayDotAlphaMax, painter ? painter.sprayDotAlphaMax : .6f, v => { if (painter) painter.sprayDotAlphaMax = v; });
// Camo knobs are material-backed and not readable yet (the HUD binds before the painter's Start);
// SyncCamoSliders re-seeds them once IsReady. Setting a slider before then is a safe no-op.
WireSlider(panel, SliderHalfLambert, painter ? painter.CamoHalfLambert : 0f, v => { if (painter) painter.CamoHalfLambert = v; });
WireSlider(panel, SliderHalfLambertBrightness, painter ? painter.CamoHalfLambertBrightness : 0f, v => { if (painter) painter.CamoHalfLambertBrightness = v; });
WireSlider(panel, SliderSpecularDamp, painter ? painter.CamoSpecularDampen : 0f, v => { if (painter) painter.CamoSpecularDampen = v; });
// Painted-height knobs: material-backed like the camo sliders above.
WireSlider(panel, SliderOcclusionThreshold, painter ? painter.OcclusionThreshold : 0.2f, v => { if (painter) painter.OcclusionThreshold = v; });
WireSlider(panel, SliderNormalIntensity, painter ? painter.HeightNormalIntensity : 1.4f, v => { if (painter) painter.HeightNormalIntensity = v; });
WireSlider(panel, SliderParallaxIntensity, painter ? painter.ParallaxIntensity : 1f, v => { if (painter) painter.ParallaxIntensity = v; });
foreach (var name in DraggablePanels)
MakeDraggable(panel, name);
}
/// <summary>
/// Pushes the painter's current values back into every widget (painter -> HUD), e.g. after a
/// pipette pick changed them outside the UI. No listeners are added here: setting a toggle/slider
/// value fires its existing change listener, which keeps the painter, value labels and panel-fade
/// alphas in sync; the colour pickers update silently via SetColor (it does not raise Changed).
/// </summary>
private void SyncFromPainter()
{
if (!_panel || !painter) return;
var panel = _panel.transform;
SetToggle(panel, ToggleAlbedo, painter.paintAlbedo);
SetToggle(panel, ToggleMetallicSmoothness, painter.paintMetallicSmoothness);
SetToggle(panel, ToggleOcclusion, painter.paintOcclusion);
SetToggle(panel, ToggleEmission, painter.paintEmission);
SetToggle(panel, ToggleSpray, painter.spray);
SetToggle(panel, TogglePipette, painter.pipette);
SetPicker(panel, PickerAlbedo, painter.brushColor);
SetPicker(panel, PickerEmission, painter.emissionColor);
SetSlider(panel, SliderAlbedoAlpha, painter.brushColor.a);
SetSlider(panel, SliderEmissionAlpha, painter.emissionColor.a);
SetSlider(panel, SliderMetallic, painter.metallic);
SetSlider(panel, SliderSmoothness, painter.smoothness);
SetSlider(panel, SliderMetallicSmoothnessAlpha, painter.metallicSmoothnessOpacity);
SetSlider(panel, SliderOcclusion, painter.occlusion);
SetSlider(panel, SliderOcclusionAlpha, painter.occlusionOpacity);
SetSlider(panel, SliderEmissionIntensity, painter.emissionIntensity);
SetSlider(panel, SliderBrushSize, painter.brushSize);
SetSlider(panel, SliderSprayDots, painter.sprayDots);
SetSlider(panel, SliderSprayDotSizeMin, painter.sprayDotSizeMin);
SetSlider(panel, SliderSprayDotSizeMax, painter.sprayDotSizeMax);
SetSlider(panel, SliderSprayDotAlphaMin, painter.sprayDotAlphaMin);
SetSlider(panel, SliderSprayDotAlphaMax, painter.sprayDotAlphaMax);
RefreshMaskHighlights();
}
/// <summary>
/// Pushes only the brush size back into its slider (painter -> HUD), e.g. after Shift+wheel
/// resized the brush in-world. The slider clamps to its own range and its listener writes the
/// clamped value back to the painter, so the wheel and the slider stay in agreement.
/// </summary>
private void SyncBrushSize()
{
if (!_panel || !painter) return;
SetSlider(_panel.transform, SliderBrushSize, painter.brushSize);
}
/// <summary>
/// Seeds the camo + height sliders from the live material (painter -> HUD). The HUD binds before the
/// painter's Start builds the instance, so the values are not readable at WirePanel time; this runs
/// once the painter reports <see cref="MeshPainter.IsReady"/> (see LateUpdate).
/// </summary>
private void SyncCamoSliders()
{
if (!_panel || !painter) return;
var panel = _panel.transform;
SetSlider(panel, SliderHalfLambert, painter.CamoHalfLambert);
SetSlider(panel, SliderHalfLambertBrightness, painter.CamoHalfLambertBrightness);
SetSlider(panel, SliderSpecularDamp, painter.CamoSpecularDampen);
// Painted-height knobs share the same material-ready timing as the camo sliders.
SetSlider(panel, SliderOcclusionThreshold, painter.OcclusionThreshold);
SetSlider(panel, SliderNormalIntensity, painter.HeightNormalIntensity);
SetSlider(panel, SliderParallaxIntensity, painter.ParallaxIntensity);
}
private static void SetToggle(Transform panel, string name, bool value)
{
var toggle = Find<Toggle>(panel, name);
if (toggle) toggle.isOn = value; // only fires onValueChanged when it actually changes
}
private static void SetSlider(Transform panel, string name, float value)
{
var slider = FindSlider(panel, name, out _);
if (!slider) return;
var curve = slider.GetComponent<SliderCurve>();
if (curve) curve.SetOutput(value); // inverts the value onto the handle, then fires the slider listener
else slider.value = value; // listener refreshes the value label + painter
}
private static void SetPicker(Transform panel, string name, Color color)
{
var picker = Find<HsvColorPicker>(panel, name);
if (picker) picker.SetColor(color);
}
// Split colour editing: the picker drives RGB, the alpha slider drives alpha - neither clobbers the other.
private static Color WithRgb(Color current, Color rgb) => new Color(rgb.r, rgb.g, rgb.b, current.a);
private static Color WithAlpha(Color current, float a) => new Color(current.r, current.g, current.b, a);
// Active = accent frame, inactive = subtle frame. Selection lives on the painter (activeBrushIndex);
// the swatch click only sets it - the painter resolves the new mask on its next Update.
private static readonly Color MaskFrameSelected = new Color(0.30f, 0.62f, 1f, 1f);
private static readonly Color MaskFrameUnselected = new Color(0.18f, 0.18f, 0.21f, 1f);
/// <summary>
/// Binds the baked mask swatches (one per <c>MaskSwatch_i</c>) to the painter's brush masks:
/// each swatch previews <c>painter.brushMasks[i]</c> and selects it on click. A swatch with no
/// matching mask is hidden, so the prefab can carry more swatches than the painter has masks.
/// </summary>
private void WireMaskSelector(Transform panel)
{
var selector = GeometryUtil.FindDeep(panel, MaskSelector);
if (!selector) return;
var masks = painter ? painter.brushMasks : null;
for (int i = 0; ; i++)
{
var swatch = GeometryUtil.FindDeep(selector, MaskSwatchPrefix + i);
if (!swatch) break;
bool has = masks != null && i < masks.Count && masks[i];
swatch.gameObject.SetActive(has);
if (!has) continue;
var preview = swatch.Find("Preview");
var previewImg = preview ? preview.GetComponent<Image>() : null;
if (previewImg) previewImg.sprite = MaskSprite(masks[i]);
var btn = swatch.GetComponent<Button>();
if (btn)
{
int idx = i; // capture per swatch
Hook(btn, () => SelectMask(idx));
}
}
RefreshMaskHighlights();
}
private void SelectMask(int index)
{
if (painter) painter.activeBrushIndex = index;
RefreshMaskHighlights();
}
private void RefreshMaskHighlights()
{
if (!_panel || !painter) return;
var selector = GeometryUtil.FindDeep(_panel.transform, MaskSelector);
if (!selector) return;
for (int i = 0; ; i++)
{
var swatch = GeometryUtil.FindDeep(selector, MaskSwatchPrefix + i);
if (!swatch) break;
var frame = swatch.GetComponent<Image>();
if (frame) frame.color = i == painter.activeBrushIndex ? MaskFrameSelected : MaskFrameUnselected;
}
}
// One sprite per mask texture, reused across rewires (display only - no CPU readback needed).
private static readonly System.Collections.Generic.Dictionary<Texture2D, Sprite> _maskSprites
= new System.Collections.Generic.Dictionary<Texture2D, Sprite>();
private static Sprite MaskSprite(Texture2D tex)
{
if (!tex) return null;
if (_maskSprites.TryGetValue(tex, out var cached) && cached) return cached;
var s = ToSprite(tex);
_maskSprites[tex] = s;
return s;
}
/// <summary>
/// Attaches (idempotently) a <see cref="DraggablePanel"/> to the whole panel so it can be dragged
/// from any of its non-interactive surface (header, padding, labels, background); the component
/// ignores presses that start on a widget (toggle, slider, colour picker).
/// </summary>
private void MakeDraggable(Transform root, string panelName)
{
var panel = GeometryUtil.FindDeep(root, panelName) as RectTransform;
if (!panel) return;
var drag = panel.GetComponent<DraggablePanel>();
if (!drag) drag = panel.gameObject.AddComponent<DraggablePanel>();
drag.target = panel;
drag.Moved = OnPanelMoved; // persist the layout the moment a panel is dropped
}
private void EnsureFonts(Transform panel)
{
foreach (var t in panel.GetComponentsInChildren<Text>(true))
if (!t.font) t.font = _font;
}
private static void WireToggle(Transform panel, string name, bool value, Action<bool> onChanged)
{
var toggle = Find<Toggle>(panel, name);
if (!toggle) return;
toggle.isOn = value;
toggle.onValueChanged.AddListener(v => onChanged(v));
}
/// <summary>
/// Ensures a <see cref="CanvasGroup"/> on the channel panel and drives its alpha from the toggle:
/// 1 while the channel is enabled, 0.5 while disabled. Call after <see cref="WireToggle"/> so the
/// initial alpha reflects the toggle's painter-synced state. The group stays interactive (default
/// interactable/blocksRaycasts), so a dimmed panel can still be clicked to re-enable its channel.
/// </summary>
private static void WirePanelFade(Transform root, string panelName, string toggleName)
{
var panel = GeometryUtil.FindDeep(root, panelName);
if (!panel) return;
var group = panel.GetComponent<CanvasGroup>();
if (!group) group = panel.gameObject.AddComponent<CanvasGroup>();
var toggle = Find<Toggle>(panel, toggleName);
if (!toggle) { group.alpha = 1f; return; }
group.alpha = toggle.isOn ? 1f : 0.5f;
toggle.onValueChanged.AddListener(v => group.alpha = v ? 1f : 0.5f);
}
/// <summary>Finds a slider cell by name and the Slider inside it (null when either is missing).</summary>
private static Slider FindSlider(Transform panel, string name, out Transform cell)
{
cell = GeometryUtil.FindDeep(panel, name);
return cell ? cell.GetComponentInChildren<Slider>() : null;
}
private static void WireSlider(Transform panel, string name, float value, Action<float> onChanged)
{
var slider = FindSlider(panel, name, out Transform cell);
if (!slider) return;
var curve = slider.GetComponent<SliderCurve>(); // optional non-linear response on the same object
var valueRt = cell.Find("Value");
var valueText = valueRt ? valueRt.GetComponent<TMPro.TMP_Text>() : null;
// With a curve the slider handle is linear but the bound value is the curved output; whole-number
// quantisation moves onto the curve so the slider itself stays continuous.
if (curve) curve.SetOutputWithoutNotify(value); // seed the handle by inverting the value (no listener yet)
else slider.value = value;
Func<float> read = () => curve ? curve.Value : slider.value;
Action<float> setText = v =>
{
if (valueText) valueText.text = slider.wholeNumbers ? Mathf.RoundToInt(v).ToString() : v.ToString("0.00");
};
setText(read()); // show the actual (clamped/curved) value, not the unclamped input
slider.onValueChanged.AddListener(_ => { float v = read(); setText(v); onChanged(v); });
}
private void WirePicker(Transform panel, string name, Color color, Action<Color> onChanged)
{
var picker = Find<HsvColorPicker>(panel, name);
if (!picker) return;
picker.Rebind(); // re-find children, re-assign runtime gradient sprites, re-hook drag relays
picker.SetColor(color);
picker.Changed += onChanged;
}
private static T Find<T>(Transform panel, string name) where T : Component
{
var child = GeometryUtil.FindDeep(panel, name);
return child ? child.GetComponent<T>() : null;
}
// Cached gradient sprites for the colour picker.
private static Sprite _satSprite, _valSprite, _hueSprite;
/// <summary>Horizontal white-out: opaque white (left) -> transparent (right) = saturation.</summary>
public static Sprite SaturationSprite()
{
if (_satSprite) return _satSprite;
const int n = 64;
var tex = NewGradientTex(n, 1);
for (int x = 0; x < n; x++)
{
byte a = (byte)Mathf.RoundToInt(255f * (1f - x / (float)(n - 1)));
tex.SetPixel(x, 0, new Color32(255, 255, 255, a));
}
tex.Apply(false);
_satSprite = ToSprite(tex);
return _satSprite;
}
/// <summary>Vertical black-out: opaque black (bottom) -> transparent (top) = value.</summary>
public static Sprite ValueSprite()
{
if (_valSprite) return _valSprite;
const int n = 64;
var tex = NewGradientTex(1, n);
for (int y = 0; y < n; y++)
{
byte a = (byte)Mathf.RoundToInt(255f * (1f - y / (float)(n - 1)));
tex.SetPixel(0, y, new Color32(0, 0, 0, a));
}
tex.Apply(false);
_valSprite = ToSprite(tex);
return _valSprite;
}
/// <summary>Horizontal full-saturation hue ramp 0..1.</summary>
public static Sprite HueSprite()
{
if (_hueSprite) return _hueSprite;
const int n = 64;
var tex = NewGradientTex(n, 1);
for (int x = 0; x < n; x++)
tex.SetPixel(x, 0, Color.HSVToRGB(x / (float)(n - 1), 1f, 1f));
tex.Apply(false);
_hueSprite = ToSprite(tex);
return _hueSprite;
}
private static Texture2D NewGradientTex(int w, int h)
{
return new Texture2D(w, h, TextureFormat.RGBA32, false, false) // sRGB sampling for correct colours
{
name = "PaintHud_Gradient",
wrapMode = TextureWrapMode.Clamp,
filterMode = FilterMode.Bilinear,
hideFlags = HideFlags.HideAndDontSave
};
}
private static Sprite ToSprite(Texture2D tex)
{
var s = Sprite.Create(tex, new Rect(0f, 0f, tex.width, tex.height), new Vector2(0.5f, 0.5f), 100f);
s.hideFlags = HideFlags.HideAndDontSave;
return s;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d0635b02a5454ec408df3a69b04f85a7
@@ -0,0 +1,84 @@
using System;
using UnityEngine;
using UnityEngine.UI;
/// <summary>
/// Reshapes a UI <see cref="Slider"/>'s response with an <see cref="AnimationCurve"/> so the value runs
/// non-linearly between the slider's authored <c>minValue</c>/<c>maxValue</c>. The curve maps the handle
/// position (x, 0..1 - linear in screen space) to a value fraction (y, 0..1) that is then scaled to the
/// slider's range. The slider's own min/max stays the single source of truth for the range; this only
/// bends the mapping inside it - so dropping the component onto an existing slider needs no range re-entry.
///
/// <see cref="Value"/> is the curved output, <see cref="SetOutput"/> positions the handle to show a given
/// output (inverting the curve), and <see cref="ValueChanged"/> raises the curved value whenever the
/// handle moves - so the component is usable both standalone (wire <see cref="ValueChanged"/>) and from
/// code that drives the raw slider (read <see cref="Value"/>).
///
/// The curve should be monotonic increasing (the default linear is); the inverse used by
/// <see cref="SetOutput"/> assumes that.
/// </summary>
[RequireComponent(typeof(Slider))]
[DisallowMultipleComponent]
public class SliderCurve : MonoBehaviour
{
[Tooltip("Maps the handle position (x, 0..1) to a value fraction (y, 0..1), scaled to the slider's " +
"minValue/maxValue. Default linear = no change. Should be monotonic increasing.")]
public AnimationCurve response = AnimationCurve.Linear(0f, 0f, 1f, 1f);
/// <summary>Raised with the curved output whenever the handle moves.</summary>
public event Action<float> ValueChanged;
private Slider _slider;
private Slider Slider => _slider ? _slider : (_slider = GetComponent<Slider>());
/// <summary>The curved output value for the current handle position.</summary>
public float Value => Slider ? Curve(Slider.normalizedValue) : 0f;
private void OnEnable()
{
if (Slider) Slider.onValueChanged.AddListener(OnSliderChanged);
}
private void OnDisable()
{
if (_slider) _slider.onValueChanged.RemoveListener(OnSliderChanged);
}
private void OnSliderChanged(float _) => ValueChanged?.Invoke(Value);
/// <summary>Positions the handle so <see cref="Value"/> reads <paramref name="output"/>, raising the
/// slider's <c>onValueChanged</c> (and thus <see cref="ValueChanged"/>).</summary>
public void SetOutput(float output)
{
if (Slider) Slider.normalizedValue = Position(output); // normalizedValue setter notifies
}
/// <summary>As <see cref="SetOutput"/> but without raising any change event (for silent seeding).</summary>
public void SetOutputWithoutNotify(float output)
{
if (Slider) Slider.SetValueWithoutNotify(Mathf.Lerp(Slider.minValue, Slider.maxValue, Position(output)));
}
// handle position (0..1) -> curved output value in [minValue, maxValue]
private float Curve(float t)
{
float y = Mathf.Clamp01(response.Evaluate(Mathf.Clamp01(t)));
float v = Mathf.Lerp(Slider.minValue, Slider.maxValue, y);
return Slider && Slider.wholeNumbers ? Mathf.Round(v) : v;
}
// curved output value -> handle position (0..1), inverting the (monotonic) curve via bisection
private float Position(float output)
{
var s = Slider;
if (!s) return 0f;
float target = Mathf.Clamp01(Mathf.InverseLerp(s.minValue, s.maxValue, output)); // desired y
float lo = 0f, hi = 1f;
for (int i = 0; i < 24; i++)
{
float mid = 0.5f * (lo + hi);
if (Mathf.Clamp01(response.Evaluate(mid)) < target) lo = mid; else hi = mid;
}
return 0.5f * (lo + hi);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2e66618c2739718459e4ecd2253c4ba7
@@ -0,0 +1,39 @@
using System;
using UnityEngine;
using UnityEngine.EventSystems;
/// <summary>
/// Turns a pointer press/drag on a UI graphic into a normalized (x, y) position in
/// [0,1] within its RectTransform (origin bottom-left). Added in code to the colour
/// picker's saturation/value field and hue bar so they react to both clicking and
/// dragging. The graphic on the same object must be a raycast target.
/// </summary>
[RequireComponent(typeof(RectTransform))]
public class UiPointerSurface : MonoBehaviour, IPointerDownHandler, IDragHandler
{
/// <summary>Fired on press and while dragging with the normalized (x, y) in [0,1].</summary>
public event Action<Vector2> Picked;
private RectTransform _rect;
private void Awake() => _rect = (RectTransform)transform;
public void OnPointerDown(PointerEventData eventData) => Report(eventData);
public void OnDrag(PointerEventData eventData) => Report(eventData);
private void Report(PointerEventData eventData)
{
// pressEventCamera is null for a Screen Space - Overlay canvas, which is the
// correct camera argument there.
if (!RectTransformUtility.ScreenPointToLocalPointInRectangle(
_rect, eventData.position, eventData.pressEventCamera, out Vector2 local))
return;
Rect r = _rect.rect;
if (r.width <= 0f || r.height <= 0f) return;
float x = Mathf.Clamp01((local.x - r.xMin) / r.width);
float y = Mathf.Clamp01((local.y - r.yMin) / r.height);
Picked?.Invoke(new Vector2(x, y));
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3aefb85fd241856408808aa5401c9d64