Persistence rework: - Drop auto-load on setup and auto-save on teardown; the painter now always seeds fresh from the material. - Named slots under MeshPaint/Saved/<name>.paint via the existing CHMP machinery: SavePaintAs / LoadPaintByName / DeletePaint / ListSavedPaintings / PaintExists. Slot files carry the display name in the header. - Remove the obsolete persistence partials (PlayerCharacterController, PlayerPoseController, ThirdPersonOrbitInput) and their references. Paintings HUD panel: - Authored Panel_Paintings + Save/Load/Delete dialogs into PaintHudPanel.prefab (TMP throughout); PaintHud.Library.cs is now a pure binder that clones the entry template per slot. - Save dialog blocks empty/existing names; Load and Delete confirm first. - Fix dialog z-order: open dialogs SetAsLastSibling so a dragged panel can no longer cover them. - Lib_ListContent horizontal offsets corrected to 0 (was clipping entry labels out of the masked viewport). Multiplayer: - Loading a painting re-syncs to all peers via the snapshot broadcast path (BroadcastLoadedPaint), covering later late-joiners for free. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
608 lines
25 KiB
C#
608 lines
25 KiB
C#
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"/> (it follows the manager's ModeChanged event) and binds its
|
|
/// widgets directly to the live <see cref="MeshPainter"/> fields, so edits apply immediately.
|
|
///
|
|
/// The layout is a transparent root holding one independently positioned sub-panel per channel
|
|
/// (Albedo, Metallic+Smoothness, Occlusion, Emission) plus a global Brush card, each with a title
|
|
/// bar and draggable from any of its non-widget surface. The layout comes from one of two sources:
|
|
/// - an authored prefab assigned to <see cref="layoutPrefab"/> (instantiated, then bound), or
|
|
/// - generated in code (the fallback when no prefab is set).
|
|
/// Either way the *bindings* (listeners, picker gradient sprites, painter values) live in code
|
|
/// and are (re)applied at runtime by <see cref="WirePanel"/>. A prefab stores only the visual
|
|
/// layout, never the behaviour, so both paths run the exact same wiring against the same stable
|
|
/// widget names - there is no separate "dead" prefab path.
|
|
///
|
|
/// Widgets: channel toggles, an HSV colour picker for albedo, metallic / smoothness / occlusion
|
|
/// sliders, an HSV picker + intensity for emission, and brush size. Attach to an empty GameObject.
|
|
/// </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. If set it is instantiated and bound; empty = build the panel in code.")]
|
|
public GameObject layoutPrefab;
|
|
|
|
// Stable widget names shared by the code-gen builder, the baked prefab and the binder.
|
|
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 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 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 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
|
|
};
|
|
|
|
private GameObject _panel;
|
|
private Font _font;
|
|
|
|
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;
|
|
}
|
|
|
|
// The paint library now lives inside the authored layout prefab; bind its widgets like the brush panel.
|
|
BindLibrary(_panel ? _panel.transform : null);
|
|
|
|
// Bind to a painter as soon as one exists. With networked spawn-on-connect there is none
|
|
// at scene start; BindLocalPlayer then raises PainterChanged when the local avatar spawns
|
|
// and OnPainterChanged wires the widgets. In a non-networked scene the painter is already
|
|
// present and binds here immediately.
|
|
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;
|
|
|
|
if (painter) painter.Picked -= SyncFromPainter;
|
|
painter = newPainter;
|
|
|
|
WirePanel(_panel.transform);
|
|
|
|
// Re-sync every widget when the pipette adopts a sampled material (painter -> HUD).
|
|
painter.Picked += SyncFromPainter;
|
|
|
|
// Restore the saved panel positions + widget values now that the painter exists.
|
|
RestoreHudState();
|
|
|
|
UpdateButtonStates(); // enable Save now that a painter is bound
|
|
}
|
|
|
|
private void OnDestroy()
|
|
{
|
|
if (painter) painter.Picked -= SyncFromPainter;
|
|
SaveHudOnTeardown();
|
|
}
|
|
|
|
private void OnModeChanged(GameMode mode)
|
|
{
|
|
bool paint = mode is PaintMode;
|
|
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; });
|
|
|
|
// Dim a channel panel to 0.5 alpha while its toggle is off, full alpha while on.
|
|
WirePanelFade(panel, PanelAlbedo, ToggleAlbedo);
|
|
WirePanelFade(panel, PanelMetallicSmoothness, ToggleMetallicSmoothness);
|
|
WirePanelFade(panel, PanelOcclusion, ToggleOcclusion);
|
|
WirePanelFade(panel, PanelEmission, ToggleEmission);
|
|
WirePanelFade(panel, PanelSpray, ToggleSpray);
|
|
|
|
WirePicker(panel, PickerAlbedo, painter ? painter.brushColor : Color.red,
|
|
c => { if (painter) painter.brushColor = c; });
|
|
|
|
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 : 1f, 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 : 1f, v => { if (painter) painter.occlusionOpacity = v; });
|
|
|
|
WirePicker(panel, PickerEmission, painter ? painter.emissionColor : Color.black,
|
|
c => { if (painter) painter.emissionColor = c; });
|
|
WireSlider(panel, SliderEmissionIntensity, painter ? painter.emissionIntensity : 1f, v => { if (painter) painter.emissionIntensity = 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 : .25f, v => { if (painter) painter.sprayDotAlphaMin = v; });
|
|
WireSlider(panel, SliderSprayDotAlphaMax, painter ? painter.sprayDotAlphaMax : 1f, v => { if (painter) painter.sprayDotAlphaMax = 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, 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();
|
|
}
|
|
|
|
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 cell = FindDeep(panel, name);
|
|
var slider = cell ? cell.GetComponentInChildren<Slider>() : null;
|
|
if (slider) 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);
|
|
}
|
|
|
|
// 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 = FindDeep(panel, MaskSelector);
|
|
if (!selector) return;
|
|
var masks = painter ? painter.brushMasks : null;
|
|
|
|
for (int i = 0; ; i++)
|
|
{
|
|
var swatch = 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
|
|
btn.onClick.RemoveAllListeners();
|
|
btn.onClick.AddListener(() => SelectMask(idx));
|
|
}
|
|
}
|
|
|
|
RefreshMaskHighlights();
|
|
}
|
|
|
|
private void SelectMask(int index)
|
|
{
|
|
if (painter) painter.activeBrushIndex = index;
|
|
RefreshMaskHighlights();
|
|
}
|
|
|
|
private void RefreshMaskHighlights()
|
|
{
|
|
if (!_panel || !painter) return;
|
|
var selector = FindDeep(_panel.transform, MaskSelector);
|
|
if (!selector) return;
|
|
|
|
for (int i = 0; ; i++)
|
|
{
|
|
var swatch = 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 = Sprite.Create(tex, new Rect(0f, 0f, tex.width, tex.height), new Vector2(0.5f, 0.5f), 100f);
|
|
s.hideFlags = HideFlags.HideAndDontSave;
|
|
_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 static void MakeDraggable(Transform root, string panelName)
|
|
{
|
|
var panel = FindDeep(root, panelName) as RectTransform;
|
|
if (!panel) return;
|
|
var drag = panel.GetComponent<DraggablePanel>();
|
|
if (!drag) drag = panel.gameObject.AddComponent<DraggablePanel>();
|
|
drag.target = panel;
|
|
}
|
|
|
|
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 = 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);
|
|
}
|
|
|
|
private static void WireSlider(Transform panel, string name, float value, Action<float> onChanged)
|
|
{
|
|
var cell = FindDeep(panel, name);
|
|
if (!cell) return;
|
|
var slider = cell.GetComponentInChildren<Slider>();
|
|
if (!slider) return;
|
|
var valueRt = cell.Find("Value");
|
|
var valueText = valueRt ? valueRt.GetComponent<TMPro.TMP_Text>() : null;
|
|
|
|
bool whole = slider.wholeNumbers;
|
|
slider.value = value;
|
|
|
|
Action<float> setText = v =>
|
|
{
|
|
if (valueText) valueText.text = whole ? Mathf.RoundToInt(v).ToString() : v.ToString("0.00");
|
|
};
|
|
setText(slider.value); // show the actual (clamped) value, not the unclamped input
|
|
slider.onValueChanged.AddListener(v => { 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 = FindDeep(panel, name);
|
|
return child ? child.GetComponent<T>() : null;
|
|
}
|
|
|
|
/// <summary>Depth-first search by name (widgets now live nested inside per-channel sub-panels).</summary>
|
|
private static Transform FindDeep(Transform root, string name)
|
|
{
|
|
if (root.name == name) return root;
|
|
for (int i = 0; i < root.childCount; i++)
|
|
{
|
|
var found = FindDeep(root.GetChild(i), name);
|
|
if (found) return found;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// --- Static UI factory (shared with HsvColorPicker) ---
|
|
|
|
public static RectTransform NewRect(string name, Transform parent)
|
|
{
|
|
var go = new GameObject(name, typeof(RectTransform));
|
|
go.transform.SetParent(parent, false);
|
|
return (RectTransform)go.transform;
|
|
}
|
|
|
|
public static Image AddImage(GameObject go, Color color)
|
|
{
|
|
var img = go.AddComponent<Image>();
|
|
img.color = color;
|
|
return img;
|
|
}
|
|
|
|
/// <summary>Adds a full-rect, non-raycasting overlay image (sprite assigned by the caller).</summary>
|
|
public static Image AddOverlay(RectTransform parent, string name)
|
|
{
|
|
var rt = NewRect(name, parent);
|
|
rt.anchorMin = Vector2.zero;
|
|
rt.anchorMax = Vector2.one;
|
|
rt.offsetMin = Vector2.zero;
|
|
rt.offsetMax = Vector2.zero;
|
|
var img = rt.gameObject.AddComponent<Image>();
|
|
img.type = Image.Type.Simple;
|
|
img.raycastTarget = false;
|
|
return img;
|
|
}
|
|
|
|
// Cached gradient sprites for the colour picker.
|
|
private static Sprite _satSprite, _valSprite, _hueSprite, _alphaSprite;
|
|
|
|
/// <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 white transparency ramp: transparent (left) -> opaque (right) = alpha.
|
|
/// Tinted to the current colour by the picker so it reads as a colour-over-backdrop fade.</summary>
|
|
public static Sprite AlphaSprite()
|
|
{
|
|
if (_alphaSprite) return _alphaSprite;
|
|
const int n = 64;
|
|
var tex = NewGradientTex(n, 1);
|
|
for (int x = 0; x < n; x++)
|
|
{
|
|
byte a = (byte)Mathf.RoundToInt(255f * (x / (float)(n - 1)));
|
|
tex.SetPixel(x, 0, new Color32(255, 255, 255, a));
|
|
}
|
|
tex.Apply(false);
|
|
_alphaSprite = ToSprite(tex);
|
|
return _alphaSprite;
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
}
|