Files
chameleon/Assets/Scripts/PaintHud.cs
T
adminandClaude Opus 4.8 9c16b9e155 Make HUD panels draggable from any non-interactive area
Drag a panel from anywhere on its surface (header, padding, labels,
background) instead of only the header. Dragging is cancelled when the
press starts on an interactable (Selectable toggle/slider or the color
picker's UiPointerSurface), so widgets stay fully operable. Clamping is
now anchor/pivot-agnostic, computed from the panel's real corners, so
panels anchored bottom-center stay reachable in every screen corner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 15:31:48 +02:00

352 lines
14 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 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 RootName = "PaintHudPanel";
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 PickerAlbedo = "Picker_Albedo";
private const string PickerEmission = "Picker_Emission";
private const string SliderMetallic = "Slider_Metallic";
private const string SliderSmoothness = "Slider_Smoothness";
private const string SliderOcclusion = "Slider_Occlusion";
private const string SliderEmissionIntensity = "Slider_EmissionIntensity";
private const string SliderBrushSize = "Slider_BrushSize";
// 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
};
private static readonly Color PanelColor = new Color(0.09f, 0.09f, 0.11f, 0.9f);
private static readonly Color HeaderColor = new Color(0.18f, 0.18f, 0.22f, 1f);
private static readonly Color Accent = new Color(0.40f, 0.75f, 1f, 1f);
private static readonly Color BoxColor = new Color(0.20f, 0.20f, 0.24f, 1f);
private GameObject _panel;
private Font _font;
private void Awake()
{
if (!modeManager) modeManager = FindFirstObjectByType<GameModeManager>();
}
private void OnEnable()
{
if (modeManager) modeManager.ModeChanged += OnModeChanged;
}
private void OnDisable()
{
if (modeManager) modeManager.ModeChanged -= OnModeChanged;
}
private void Start()
{
if (!painter) painter = modeManager ? modeManager.Painter : null;
if (!painter) painter = FindFirstObjectByType<MeshPainter>();
_font = LoadFont();
EnsureEventSystem();
EnsureCanvas();
if (layoutPrefab)
{
var go = Instantiate(layoutPrefab, transform);
go.name = layoutPrefab.name; // strip the "(Clone)" suffix
_panel = go;
}
WirePanel(_panel.transform);
// Set initial visibility (the manager's first SwitchTo may have fired before this Start).
OnModeChanged(modeManager ? modeManager.ActiveMode : null);
}
private void OnModeChanged(GameMode mode)
{
if (_panel) _panel.SetActive(mode is PaintMode);
}
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; });
WirePicker(panel, PickerAlbedo, painter ? painter.brushColor : Color.red,
c => { if (painter) painter.brushColor = new Color(c.r, c.g, c.b, painter.brushColor.a); });
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, SliderOcclusion, painter ? painter.occlusion : 0.5f, v => { if (painter) painter.occlusion = v; });
WirePicker(panel, PickerEmission, painter ? painter.emissionColor : Color.black,
c => { if (painter) painter.emissionColor = new Color(c.r, c.g, c.b, painter.emissionColor.a); });
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); });
foreach (var name in DraggablePanels)
MakeDraggable(panel, name);
}
/// <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));
}
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;
/// <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;
}
}