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 an /// authored prefab () or is generated in code as the fallback. Either way /// the BINDINGS live in code and are (re)applied at runtime by against the /// same stable widget names - a 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; [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 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(); } 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(); 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; if (painter) { painter.Picked -= SyncFromPainter; painter.BrushSizeChanged -= SyncBrushSize; } 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() { if (painter) { 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; } // --- Canvas --- private void EnsureCanvas() { var canvas = GetComponent(); if (!canvas) canvas = gameObject.AddComponent(); canvas.renderMode = RenderMode.ScreenSpaceOverlay; canvas.sortingOrder = 100; var scaler = GetComponent(); if (!scaler) scaler = gameObject.AddComponent(); scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize; scaler.referenceResolution = new Vector2(1920f, 1080f); scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.Expand; if (!GetComponent()) gameObject.AddComponent(); } private void EnsureEventSystem() { if (FindFirstObjectByType()) return; var es = new GameObject("EventSystem", typeof(EventSystem)); #if ENABLE_INPUT_SYSTEM var module = es.AddComponent(); if (!module.actionsAsset) module.AssignDefaultActions(); #else es.AddComponent(); #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); } /// /// 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); 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(); } /// /// 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 cell = FindDeep(panel, name); var slider = cell ? cell.GetComponentInChildren() : null; 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 = 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() : null; if (previewImg) previewImg.sprite = MaskSprite(masks[i]); var btn = swatch.GetComponent