using System;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
#if ENABLE_INPUT_SYSTEM
using UnityEngine.InputSystem.UI;
#endif
///
/// In-game paint UI. Shows its panels only while the active is
/// (follows the manager's ModeChanged) and binds its widgets directly to the
/// live fields, so edits apply immediately.
///
/// The layout (independently draggable sub-panels per channel + a global brush card) comes from the
/// authored prefab (). The BINDINGS live in code and are (re)applied at
/// runtime by against stable widget names - the prefab stores only the
/// visual layout, never behaviour.
///
[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;
// 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 PanelFur = "Panel_Fur";
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 ToggleFur = "Toggle_Fur";
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 SliderFurLength = "Slider_FurLength";
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,
PanelFur
};
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();
}
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();
_panel = transform.GetChild(0).gameObject;
// 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();
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);
}
///
/// (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.
///
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("LegacyRuntime.ttf");
if (!f) f = Resources.GetBuiltinResource("Arial.ttf");
return f;
}
// --- 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; });
WireToggle(panel, ToggleFur, painter && painter.fur, v => { if (painter) painter.fur = v; });
WirePanelFade(panel, PanelAlbedo, ToggleAlbedo);
WirePanelFade(panel, PanelMetallicSmoothness, ToggleMetallicSmoothness);
WirePanelFade(panel, PanelOcclusion, ToggleOcclusion);
WirePanelFade(panel, PanelEmission, ToggleEmission);
WirePanelFade(panel, PanelSpray, ToggleSpray);
WirePanelFade(panel, PanelFur, ToggleFur);
// 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; });
// Fur has its own Toggle_Fur gate (wired above); the colour comes from the albedo map and the deposit
// weight from the brush alpha, so the only extra fur control is the target length.
WireSlider(panel, SliderFurLength, painter ? painter.furLength : 0.5f,
v => { if (painter) painter.furLength = v; });
foreach (var name in DraggablePanels)
MakeDraggable(panel, name);
}
///
/// 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).
///
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);
SetToggle(panel, ToggleFur, painter.fur);
SetPicker(panel, PickerAlbedo, painter.brushColor);
SetPicker(panel, PickerEmission, painter.emissionColor);
SetSlider(panel, SliderAlbedoAlpha, painter.brushColor.a);
SetSlider(panel, SliderEmissionAlpha, painter.emissionColor.a);
SetSlider(panel, SliderFurLength, painter.furLength);
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();
}
///
/// 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.
///
private void SyncBrushSize()
{
if (!_panel || !painter) return;
SetSlider(_panel.transform, SliderBrushSize, painter.brushSize);
}
///
/// 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 LateUpdate).
///
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(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();
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(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);
///
/// Binds the baked mask swatches (one per MaskSwatch_i) to the painter's brush masks:
/// each swatch previews painter.brushMasks[i] 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.
///
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() : null;
if (previewImg) previewImg.sprite = MaskSprite(masks[i]);
var btn = swatch.GetComponent