Add in-game paint HUD bound to the mesh painter
Add a code-driven HUD that appears in paint mode and binds live to the active MeshPainter: four channel toggles, HSV pickers for albedo and emission, sliders for metallic/smoothness/occlusion, emission intensity and brush size. The HUD self-assembles its own overlay canvas and event system at runtime. Add a ModeChanged event to GameModeManager so the HUD can follow the active mode, and guard MeshPainter against painting through the UI. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
6555db1487
commit
04c22f0ac1
8 files changed
+594
No files matched your search
@@ -0,0 +1,407 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using UnityEngine.EventSystems;
|
||||
#if ENABLE_INPUT_SYSTEM
|
||||
using UnityEngine.InputSystem.UI;
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// In-game paint UI. Builds its own Screen Space - Overlay canvas (and an EventSystem
|
||||
/// if the scene has none) entirely in code and binds the widgets directly to the live
|
||||
/// <see cref="MeshPainter"/> fields, so changes apply immediately. The panel is shown
|
||||
/// only while the active <see cref="GameMode"/> is <see cref="PaintMode"/> (it follows
|
||||
/// the manager's ModeChanged event).
|
||||
///
|
||||
/// First, simple pass: 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; everything else is generated at runtime.
|
||||
/// </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;
|
||||
|
||||
private static readonly Color PanelColor = new Color(0.09f, 0.09f, 0.11f, 0.9f);
|
||||
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 = Resources.GetBuiltinResource<Font>("LegacyRuntime.ttf");
|
||||
if (!_font) _font = Resources.GetBuiltinResource<Font>("Arial.ttf");
|
||||
|
||||
EnsureEventSystem();
|
||||
BuildUi();
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// --- UI construction ---
|
||||
|
||||
private void BuildUi()
|
||||
{
|
||||
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.matchWidthOrHeight = 0.5f;
|
||||
|
||||
if (!GetComponent<GraphicRaycaster>()) gameObject.AddComponent<GraphicRaycaster>();
|
||||
|
||||
// Panel (this is what gets shown/hidden per mode).
|
||||
var panel = NewRect("Panel", transform);
|
||||
_panel = panel.gameObject;
|
||||
panel.anchorMin = panel.anchorMax = new Vector2(0f, 1f);
|
||||
panel.pivot = new Vector2(0f, 1f);
|
||||
panel.anchoredPosition = new Vector2(16f, -16f);
|
||||
panel.sizeDelta = new Vector2(296f, 0f); // width fixed; height from the ContentSizeFitter
|
||||
AddImage(panel.gameObject, PanelColor);
|
||||
|
||||
var vlg = panel.gameObject.AddComponent<VerticalLayoutGroup>();
|
||||
vlg.padding = new RectOffset(12, 12, 12, 12);
|
||||
vlg.spacing = 8f;
|
||||
vlg.childAlignment = TextAnchor.UpperLeft;
|
||||
vlg.childControlWidth = true;
|
||||
vlg.childControlHeight = true;
|
||||
vlg.childForceExpandWidth = true;
|
||||
vlg.childForceExpandHeight = false;
|
||||
|
||||
var fitter = panel.gameObject.AddComponent<ContentSizeFitter>();
|
||||
fitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
|
||||
|
||||
AddSectionLabel(panel, "PAINT", 15, FontStyle.Bold);
|
||||
|
||||
// Channels.
|
||||
AddSectionLabel(panel, "Channels", 13, FontStyle.Bold);
|
||||
AddToggle(panel, "Albedo", painter && painter.paintAlbedo, v => { if (painter) painter.paintAlbedo = v; });
|
||||
AddToggle(panel, "Metallic + Smoothness", painter && painter.paintMetallicSmoothness, v => { if (painter) painter.paintMetallicSmoothness = v; });
|
||||
AddToggle(panel, "Occlusion", painter && painter.paintOcclusion, v => { if (painter) painter.paintOcclusion = v; });
|
||||
AddToggle(panel, "Emission", painter && painter.paintEmission, v => { if (painter) painter.paintEmission = v; });
|
||||
|
||||
// Albedo colour.
|
||||
AddSectionLabel(panel, "Albedo Color", 13, FontStyle.Bold);
|
||||
var albedo = AddColorPicker(panel);
|
||||
albedo.SetColor(painter ? painter.brushColor : Color.red);
|
||||
albedo.Changed += c => { if (painter) painter.brushColor = new Color(c.r, c.g, c.b, painter.brushColor.a); };
|
||||
|
||||
// Metallic / smoothness / occlusion.
|
||||
AddSlider(panel, "Metallic", 0f, 1f, false, painter ? painter.metallic : 0f, v => { if (painter) painter.metallic = v; });
|
||||
AddSlider(panel, "Smoothness", 0f, 1f, false, painter ? painter.smoothness : 0.5f, v => { if (painter) painter.smoothness = v; });
|
||||
AddSlider(panel, "Occlusion", 0f, 1f, false, painter ? painter.occlusion : 0.5f, v => { if (painter) painter.occlusion = v; });
|
||||
|
||||
// Emission.
|
||||
AddSectionLabel(panel, "Emission Color", 13, FontStyle.Bold);
|
||||
var emission = AddColorPicker(panel);
|
||||
emission.SetColor(painter ? painter.emissionColor : Color.black);
|
||||
emission.Changed += c => { if (painter) painter.emissionColor = new Color(c.r, c.g, c.b, painter.emissionColor.a); };
|
||||
AddSlider(panel, "Emission Intensity", 0f, 8f, false, painter ? painter.emissionIntensity : 1f, v => { if (painter) painter.emissionIntensity = v; });
|
||||
|
||||
// Brush size.
|
||||
AddSlider(panel, "Brush Size", 1f, 256f, true, painter ? painter.brushSize : 16f, v => { if (painter) painter.brushSize = Mathf.RoundToInt(v); });
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// --- Widgets ---
|
||||
|
||||
private void AddSectionLabel(RectTransform panel, string text, int size, FontStyle style)
|
||||
{
|
||||
var rt = NewRect("Label", panel);
|
||||
var le = rt.gameObject.AddComponent<LayoutElement>();
|
||||
le.minHeight = le.preferredHeight = 18f;
|
||||
|
||||
var t = MakeText(rt.gameObject, text, size, style, new Color(0.85f, 0.85f, 0.9f, 1f), TextAnchor.MiddleLeft);
|
||||
t.raycastTarget = false;
|
||||
}
|
||||
|
||||
private void AddToggle(RectTransform panel, string label, bool value, Action<bool> onChanged)
|
||||
{
|
||||
var rt = NewRect("Toggle", panel);
|
||||
var le = rt.gameObject.AddComponent<LayoutElement>();
|
||||
le.minHeight = le.preferredHeight = 22f;
|
||||
|
||||
var toggle = rt.gameObject.AddComponent<Toggle>();
|
||||
|
||||
var box = NewRect("Box", rt);
|
||||
box.anchorMin = box.anchorMax = new Vector2(0f, 0.5f);
|
||||
box.pivot = new Vector2(0f, 0.5f);
|
||||
box.anchoredPosition = Vector2.zero;
|
||||
box.sizeDelta = new Vector2(18f, 18f);
|
||||
var boxImg = AddImage(box.gameObject, BoxColor);
|
||||
|
||||
var check = NewRect("Check", box);
|
||||
check.anchorMin = Vector2.zero;
|
||||
check.anchorMax = Vector2.one;
|
||||
check.offsetMin = new Vector2(3f, 3f);
|
||||
check.offsetMax = new Vector2(-3f, -3f);
|
||||
var checkImg = AddImage(check.gameObject, Accent);
|
||||
checkImg.raycastTarget = false;
|
||||
|
||||
var lbl = NewRect("Label", rt);
|
||||
lbl.anchorMin = Vector2.zero;
|
||||
lbl.anchorMax = Vector2.one;
|
||||
lbl.offsetMin = new Vector2(24f, 0f);
|
||||
lbl.offsetMax = Vector2.zero;
|
||||
var t = MakeText(lbl.gameObject, label, 14, FontStyle.Normal, Color.white, TextAnchor.MiddleLeft);
|
||||
t.raycastTarget = false;
|
||||
|
||||
toggle.targetGraphic = boxImg;
|
||||
toggle.graphic = checkImg;
|
||||
toggle.isOn = value;
|
||||
toggle.onValueChanged.AddListener(v => onChanged(v));
|
||||
}
|
||||
|
||||
private void AddSlider(RectTransform panel, string label, float min, float max, bool whole, float value, Action<float> onChanged)
|
||||
{
|
||||
var rt = NewRect("Slider", panel);
|
||||
var le = rt.gameObject.AddComponent<LayoutElement>();
|
||||
le.minHeight = le.preferredHeight = 34f;
|
||||
|
||||
// Caption row (label left, value right), pinned to the top of the cell.
|
||||
var lbl = NewRect("Label", rt);
|
||||
lbl.anchorMin = new Vector2(0f, 1f);
|
||||
lbl.anchorMax = new Vector2(1f, 1f);
|
||||
lbl.pivot = new Vector2(0f, 1f);
|
||||
lbl.offsetMin = new Vector2(0f, -16f);
|
||||
lbl.offsetMax = new Vector2(-48f, 0f);
|
||||
var lt = MakeText(lbl.gameObject, label, 13, FontStyle.Normal, new Color(0.8f, 0.8f, 0.85f, 1f), TextAnchor.MiddleLeft);
|
||||
lt.raycastTarget = false;
|
||||
|
||||
var valRt = NewRect("Value", rt);
|
||||
valRt.anchorMin = new Vector2(1f, 1f);
|
||||
valRt.anchorMax = new Vector2(1f, 1f);
|
||||
valRt.pivot = new Vector2(1f, 1f);
|
||||
valRt.anchoredPosition = Vector2.zero;
|
||||
valRt.sizeDelta = new Vector2(56f, 16f);
|
||||
var vt = MakeText(valRt.gameObject, "", 13, FontStyle.Normal, Color.white, TextAnchor.MiddleRight);
|
||||
vt.raycastTarget = false;
|
||||
|
||||
// Track, pinned to the bottom of the cell.
|
||||
var track = NewRect("Track", rt);
|
||||
track.anchorMin = new Vector2(0f, 0f);
|
||||
track.anchorMax = new Vector2(1f, 0f);
|
||||
track.pivot = new Vector2(0.5f, 0f);
|
||||
track.anchoredPosition = Vector2.zero;
|
||||
track.sizeDelta = new Vector2(0f, 16f);
|
||||
var slider = track.gameObject.AddComponent<Slider>();
|
||||
|
||||
var bg = NewRect("Background", track);
|
||||
bg.anchorMin = new Vector2(0f, 0.5f);
|
||||
bg.anchorMax = new Vector2(1f, 0.5f);
|
||||
bg.pivot = new Vector2(0.5f, 0.5f);
|
||||
bg.anchoredPosition = Vector2.zero;
|
||||
bg.sizeDelta = new Vector2(0f, 5f);
|
||||
AddImage(bg.gameObject, BoxColor); // raycast target so clicking the bar jumps the value
|
||||
|
||||
var fillArea = NewRect("Fill Area", track);
|
||||
fillArea.anchorMin = new Vector2(0f, 0.5f);
|
||||
fillArea.anchorMax = new Vector2(1f, 0.5f);
|
||||
fillArea.pivot = new Vector2(0.5f, 0.5f);
|
||||
fillArea.anchoredPosition = Vector2.zero;
|
||||
fillArea.sizeDelta = new Vector2(-16f, 5f);
|
||||
var fill = NewRect("Fill", fillArea);
|
||||
fill.anchorMin = Vector2.zero;
|
||||
fill.anchorMax = Vector2.one;
|
||||
fill.offsetMin = Vector2.zero;
|
||||
fill.offsetMax = Vector2.zero;
|
||||
var fillImg = AddImage(fill.gameObject, Accent);
|
||||
fillImg.raycastTarget = false;
|
||||
|
||||
var handleArea = NewRect("Handle Slide Area", track);
|
||||
handleArea.anchorMin = Vector2.zero;
|
||||
handleArea.anchorMax = Vector2.one;
|
||||
handleArea.offsetMin = new Vector2(8f, 0f);
|
||||
handleArea.offsetMax = new Vector2(-8f, 0f);
|
||||
var handle = NewRect("Handle", handleArea);
|
||||
handle.sizeDelta = new Vector2(14f, 0f);
|
||||
var handleImg = AddImage(handle.gameObject, Color.white);
|
||||
|
||||
slider.fillRect = fill;
|
||||
slider.handleRect = handle;
|
||||
slider.targetGraphic = handleImg;
|
||||
slider.direction = Slider.Direction.LeftToRight;
|
||||
slider.minValue = min;
|
||||
slider.maxValue = max;
|
||||
slider.wholeNumbers = whole;
|
||||
slider.value = value;
|
||||
|
||||
Action<float> setText = v => vt.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 HsvColorPicker AddColorPicker(RectTransform panel)
|
||||
{
|
||||
const float square = 120f;
|
||||
const float hueH = 14f;
|
||||
|
||||
var rt = NewRect("ColorPicker", panel);
|
||||
var le = rt.gameObject.AddComponent<LayoutElement>();
|
||||
le.minHeight = le.preferredHeight = square + 6f + hueH;
|
||||
|
||||
var picker = rt.gameObject.AddComponent<HsvColorPicker>();
|
||||
picker.Build(square, hueH);
|
||||
return picker;
|
||||
}
|
||||
|
||||
private Text MakeText(GameObject go, string text, int size, FontStyle style, Color color, TextAnchor anchor)
|
||||
{
|
||||
var t = go.AddComponent<Text>();
|
||||
t.font = _font;
|
||||
t.fontSize = size;
|
||||
t.fontStyle = style;
|
||||
t.color = color;
|
||||
t.alignment = anchor;
|
||||
t.text = text;
|
||||
t.horizontalOverflow = HorizontalWrapMode.Overflow;
|
||||
t.verticalOverflow = VerticalWrapMode.Truncate;
|
||||
return t;
|
||||
}
|
||||
|
||||
// --- 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 with the given sprite.</summary>
|
||||
public static Image AddOverlay(RectTransform parent, Sprite sprite)
|
||||
{
|
||||
var rt = NewRect("Overlay", parent);
|
||||
rt.anchorMin = Vector2.zero;
|
||||
rt.anchorMax = Vector2.one;
|
||||
rt.offsetMin = Vector2.zero;
|
||||
rt.offsetMax = Vector2.zero;
|
||||
var img = rt.gameObject.AddComponent<Image>();
|
||||
img.sprite = sprite;
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user