在本文中,我将使用UI Toolkit实现自定义的插槽、指示器等部件UI。

文章的子章节大致以由简至难排布。


与以往相同,在开始之前,我先给出本文所实现的效果。

  1. 高性能指示器
    在这里插入图片描述

  2. 可控的过渡动画(指示线的逻辑)
    在这里插入图片描述

  3. 高级属性面板(完全由自定义组件组成)


    5. 属性来源指示器在这里插入图片描述


ℹ️信息

  • 此片文章着重讲解UI的实现,武器属性遗传系统非文章重点
  • 在每一节标题我会给出关键的注意事项,这样即使你对此效果不感兴趣,也应该能够挑选有价值的部分细读。


部件指示器

ℹ️实现两个组件指示线插槽

  • 指示线组件,根据起点与末点,在屏幕上绘制出直线。
  • 插槽组件,可进行点击交互,处理鼠标相关的事件。

ℹ️实现一种Label平替元素DynamicLabelElement:实现逐字拼写、乱码特效。

结构图

指示线组件 IndicatorElement

ℹ️此节将会对实现自定义组件的基础进行详细介绍。

ℹ️在先前的文章中,我有几篇UI Toolkit的自定义组件的基础教程。实现自定义UI时买必须确保:

  • 至少确保其继承自VisualElement或其子类。
  • 需要实现类型为UxmlFactory<你的组件名>的Factory。
  • 如果你希望能够在UI Builder中添加可调整的自定义的属性,需要实现UxmlTraits,并在将Factory的类型改为UxmlFactory<你的组件名,你的UxmlTraits>

⚠️注意:UxmlFactoryUxmlTraits用于与UI Builder桥接。因此若完全不实现此两者,则无法在UI Builder中使用,但依然可以在代码中进行构造使用。
无论如何总是推荐至少实现UxmlFactory,这是官方的约定俗成。

指示线IndicatorElement 继承自VisualElement

指示线IndicatorElement 主要由进行代码控制生成,可以不用实现UxmlTraits。但为方便直接在UI Builder中查看效果,此处依然实现UxmlTraits

IndicatorElement 的代码框架如下:

public class IndicatorElementUxmlFactory : UxmlFactory<IndicatorElement,IndicatorElementUxmlTraits> {}
public class IndicatorElementUxmlTraits : VisualElement.UxmlTraits
{
	...元素向UI Builder暴露的属性...
}
public class IndicatorElement : VisualElement
{
	...元素实际逻辑
}

IndicatorElement 指示线作为容器,承载关于此指示点的说明性元素。例如:
需要指示枪管时,指示线的一端将指向场景中的枪管位置。而另一端将显示一个自定义的元素(例如Label),此Label将作为指示线的子元素。

属性设计

为了描述线段的信息,我决定使用一个额外的Vector2表示终点,元素本身的位置表示起点
其属性如下:

变量名 类型 作用
targetPoint Vector 记录指示线的终点
lineColor Color 表示线的颜色
lineWidth float 表示线的宽度
blockSize float 表示插槽的大小

为了能暴露上面的属性,需要在UxmlTraits中添加对应的UxmlAttributeDescription

    public class IndicatorElementUxmlTraits : VisualElement.UxmlTraits
{
    public override void Init(VisualElement ve, IUxmlAttributes bag, CreationContext cc)
    {
        base.Init(ve, bag, cc);
        var line = ((IndicatorElement)ve);
        line.TargetPointX = _targetPointX.GetValueFromBag(bag, cc);
        line.TargetPointY = _targetPointY.GetValueFromBag(bag, cc);            
        line.LineWidth = _lineWidth.GetValueFromBag(bag, cc);
        line.BlockSize = _blockSize.GetValueFromBag(bag, cc);
        line.LineColor = _lineColor.GetValueFromBag(bag, cc);
    }
    private readonly UxmlFloatAttributeDescription _lineWidth = new()
    {
        name = "line-width",
        defaultValue = 1f
    };
    private readonly UxmlFloatAttributeDescription _targetPointX = new()
    {
        name = "target-point-x",
        defaultValue = 0f
    };
    private readonly UxmlFloatAttributeDescription _targetPointY = new()
    {
        name = "target-point-y",
        defaultValue = 0f
    };
    private readonly UxmlFloatAttributeDescription _blockSize = new ()
    {
        name = "block-size",
        defaultValue = 20f
    };
    private readonly UxmlColorAttributeDescription _lineColor = new()
    {
        name = "line-color", 
        defaultValue = Color.black
    };
}

⚠️注意:
UxmlAttributeDescriptionname属性,必须采用连字符命名法(单词之间用连字符 - 分隔)
UxmlAttributeDescriptionname属性,必须与C#属性名相似(如test-pTestPtestPtestp是相似的),否则UI Builder无法正确的读取内容。
UxmlAttributeDescription不存在Vector2的支持,因此需要将TargetPoint拆分为两个float

IndicatorElement类的C#属性代码为:

public class IndicatorElement : VisualElement{
    // 暴露的C#属性
    public float TargetPointX{
        get => _targetPoint.x;
        set => _targetPoint.x = value;
    }
    public float TargetPointY{
        get => _targetPoint.y;
        set => _targetPoint.y = value;
    }
    public Vector2 TargetPoint {
        get => _targetPoint;
        set{
            _targetPoint = value;
            MarkDirtyRepaint(); // 标记为需要重绘
        }
    }
    public float LineWidth{
        get => _lineWidth;
        set{
            _lineWidth = value;
            MarkDirtyRepaint(); // 标记为需要重绘
        }
    }
    public float BlockSize{
        get => _blockSize;
        set => _blockSize = f;
    }
    public Color LineColor{
        get => _lineColor;
        set{
            _lineColor = value;
            MarkDirtyRepaint(); // 标记为需要重绘
        }
    }
    //便捷属性,快速设置起点位置
    public Vector2 Position{
        get => new(style.left.value.value, style.top.value.value);
        set{
            style.left = value.x;
            style.top = value.y;
        }
    }
    
    private Vector2 _targetPoint;
    private Color _lineColor;
    private float _blockSize;
    private float _lineWidth;

    public IndicatorElement(){
        //构造函数
    }
}

⚠️注意:
所有被UxmlTraitsUxmlAttributeDescriptionname属性,不能与类内任何类型冲突C#属性名相似,例如:

  • UxmlTraits存在一个UxmlFloatAttributeDescriptionnametest-p的属性,则IndicatorElement类内不允许任何类型与float冲突、且名称与TestPtestPtestp相同的C#属性,即使此C#属性UxmlTraits的属性完全无关、对外不可见。

这个“特性”十分无厘头,或许可归结为Bug。

绘制自定义2D形状

通过订阅generateVisualContent委托,实现绘制自定义2D形状。
借助上文规定的属性,绘制出预计形状。

//位于IndicatorElement类中
// ...省略其他代码
public IndicatorElement()
{
    style.position = UnityEngine.UIElements.Position.Absolute;
    style.width = 0;
    style.height = 0;
    //订阅委托
    generateVisualContent += DrawIndicatorLine;
}
private void DrawIndicatorLine(MeshGenerationContext mgc)
{
    var painter = mgc.painter2D;
    painter.lineWidth = _lineWidth;
    painter.strokeColor = _lineColor;
    painter.lineCap = LineCap.Round;
    var element = mgc.visualElement;
    
    var blockCenter = element.WorldToLocal(_targetPoint);
    // 绘制一条线
    painter.BeginPath();
    painter.MoveTo(element.contentRect.center);
    painter.LineTo(blockCenter);
    // 绘制线段时使用渐变的颜色
    painter.strokeGradient = new Gradient()
    {
        colorKeys = new GradientColorKey[]
        {
            new() { color = _lineColor, time = 0.0f },
            new() { color = _lineColor, time = 1.0f }
        },
        alphaKeys = new GradientAlphaKey[]
        {
            new (){alpha = 0.0f, time = 0.0f},
            new (){alpha = 1.0f, time = 1.0f}
        }
    };
    // 绘制线段
    painter.Stroke();
    // 接下来无需渐变
    painter.strokeGradient = null;
    
    //在targetPoint上绘制一个方块
    painter.BeginPath();
    painter.MoveTo(blockCenter - new Vector2(_blockSize,_blockSize));
    painter.LineTo(blockCenter + new Vector2(_blockSize,-_blockSize));
    painter.LineTo(blockCenter + new Vector2(_blockSize,_blockSize));
    painter.LineTo(blockCenter + new Vector2(-_blockSize,_blockSize));
    painter.LineTo(blockCenter - new Vector2(_blockSize,_blockSize));
    painter.ClosePath();
    // 绘制线段
    painter.Stroke();
}

ℹ️ 在generateVisualContent的委托事件中,始终将VisualElement视为“只读”,并在不引起副作用的情况进行绘制相关的处理。在此事件期间对VisualElement所做的更改可能会丢失或至少是滞后出现。

ℹ️ 仅当Unity检测到VisualElement需要重新生成其可视内容时,Unity才调用generateVisualContent委托。因此当自定义属性的数据改变时,画面可能无法及时更新,使用MarkDirtyRepaint()方法,可以强制触发重绘。

generateVisualContent中进行的任何绘制,其坐标始终基于委托所属元素的局部坐标系。 因此在绘制终点时,需要使用WorldToLocal方法,将世界坐标转换回IndicatorElement的本地坐标系中:

var element = mgc.visualElement;
var blockCenter = element.WorldToLocal(_targetPoint);

其中element是本generateVisualContent委托的所属VE,在这里,elementIndicatorElement实例。

ℹ️UI Toolkit的世界坐标系以左上角为原点

此时可以直接在UI Builder中查看效果,通过调整属性检查是否正常工作。
在这里插入图片描述

⚠️ 注意:
由于使用了世界坐标,而UI Builder本身以UI Toolkit构建,因此绘制的内容会突破UI Builder的范围。

插槽组件 SocketElement

ℹ️此节将着重对使用代码生成元素结构控制元素动态表现的技巧进行介绍。

结构 效果
1 在这里插入图片描述

插槽 SocketElement 继承自VisualElement
插槽 SocketElement 主要由进行代码控制生成,可以不用实现UxmlTraits。但为方便直接在UI Builder中查看效果,此处依然实现UxmlTraits

SocketElement 代码框架如下:

    public class SocketElementUxmlFactory : UxmlFactory<SocketElement, SocketElementUxmlTraits> {}
    public class SocketElementUxmlTraits : VisualElement.UxmlTraits{
        ...
    }
    public class SocketElement : VisualElement{
        ...    
    }

使用代码生成结构与设置style

与指示线组件不同,插槽组件的布局结构更复杂,因此先着手生成结构,其后再处理属性。其结构如下:
content(VisualElement)
├─socket-area(Button):主体按钮,用于接收点击事件
│ ├─stripe-background(StripeBackgroundElement):插槽图标的背景(显示一个循环滚动的背景)
│ │ └─socket-image(Image):插槽图标
│ └─content-image(Image):安装的组件图标
├─label-background(VisualElement):标签背景
│ └─socket-label (Label):标题标签
└─second-label-background(VisualElement):次标签背景
└─second-label(Label):次标签

  1. StripeBackgroundElement是自定义的组件,用于显示无限上下滚动的条纹,将会在后文进行实现。Image是内置的图片组件,用于显示图标。
  2. 插槽图标安装的组件图标只能显示其中一个,即stripe-backgroundcontent-image互斥。

通过代码直接在创建组件时分配style属性,若你有HTML编程基础,则应该很容易理清楚属性的意义。生成上述结构树的完整代码如下:

public class SocketElement : VisualElement{
    private readonly VisualElement _content;
    private readonly Button _button;
    private readonly Image _contentImage;
    private readonly Image _socketImage;
    private readonly VectorImageElement _stripeBackground;
    private readonly Label _label;
    private readonly VisualElement _labelBackground;
    private readonly Label _secondLabel;
    private readonly VisualElement _secondLabelBackground;
    // 绑定按钮的点击事件
    public void BindClickEvent(Action<> clickEvent){
        _button.clicked += clickEvent;
    }
    public SocketElement() {
        _content = new VisualElement {
            style= {
                position = Position.Absolute,
                width = 100, height = 100,
                translate = new Translate(Length.Percent(-50f),Length.Percent(-50f)),
            },
            name = "socket-content"
        };
        _contentImage = new Image() {
            style = {
                position = Position.Absolute,
                top = 0, left = 0, bottom = 0, right= 0,
                marginBottom = 0, marginLeft = 0, marginRight = 0, marginTop = 0,
                paddingBottom = 0, paddingTop = 0, paddingLeft = 0, paddingRight = 0
            },
            name = "content-image",
        };
        _labelBackground = new VisualElement()
        {
            style = {
                position = Position.Absolute,
                bottom = Length.Percent(100f),
                marginBottom = 0, marginLeft = 0, marginRight = 0, marginTop = 0,
                paddingBottom = 0, paddingTop = 0
            },
            name = "socket-name-background",                
        };
        _secondLabelBackground = new VisualElement() {
            style = {
                position = Position.Absolute,
                top = Length.Percent(100f),
                marginBottom = 0, marginLeft = 0, marginRight = 0, marginTop = 0,
                paddingBottom = 0, paddingTop = 0,
                display = DisplayStyle.None
            },
            name = "second-name-background",                
        };
        _label = new Label("") {
            style = {
                marginBottom = 0, marginLeft = 0, marginRight = 0, marginTop = 0,
                paddingBottom = 0, paddingTop = 0,
                unityFontStyleAndWeight = FontStyle.Bold
            },
            name = "socket-name",
        };
        _secondLabel = new Label("") {
            style = {
                marginBottom = 0, marginLeft = 0, marginRight = 0, marginTop = 0,
                paddingBottom = 0, paddingTop = 0,
                unityFontStyleAndWeight = FontStyle.Bold
            },
            name = "second-name",
        };
        _button = new Button {
            style = {
                position = Position.Absolute,
                top = 0, left = 0, right = 0, bottom = 0,
                marginBottom = 0, marginLeft = 0, marginRight = 0, marginTop = 0,
                borderTopWidth = 0, borderRightWidth = 0, borderBottomWidth = 0, borderLeftWidth = 0,
                backgroundColor = Color.clear
            },
            name = "socket-area"
        };
        _stripeBackground = new VectorImageElement {
            name = "stripe-background"
        };
        _socketImage = new Image {
            style = {
                position = Position.Absolute,
                top = 0, left = 0, right = 0, bottom = 0
            },
            name = "socket-image"
        };
        _stripeBackground.Add(_socketImage);
        _button.Add(_stripeBackground);
        _button.Add(_contentImage);
        _content.Add(_button);
        _labelBackground.Add(_label);
        _secondLabelBackground.Add(_secondLabel);
        _content.Add(_labelBackground);
        _content.Add(_secondLabelBackground);
        Add(_content);
        _content.generateVisualContent += DrawBorder;
    }
    private void DrawBorder(MeshGenerationContext mgc){
        ...绘制边框代码...    
    }
}

ℹ️注意:

  1. 使用代码设置style的百分比值时,须使用Length.Percent()
  2. style的类型与C#、Unity基础类型不同,通常其基础类型都能够隐式的转换为对应的Style类型,例如:
  • float可转换为StyleFloat
  • Color可转换为StyleColor
    反向转换则行不通,需要使用value进行拆箱,例如:
  • StyleFloat.value -> float
  • StyleColor.value -> Color

其中的VectorImageElement类型为自定义元素,用于播放动画背景图案,下一节中我将实现它的代码,目前可改为VisualElement类型。

推荐生成元素时同时指定name属性,由此可更灵活的通过USS控制样式,以便支持未来可能需要的USS样式换肤。

属性设计

目前,插槽包含如下几个属性:

变量名 类型 作用
HideSocket bool 是否隐藏插槽
Opacity float 透明度
AnimOpacity float 透明度,设置此将会使用动画过渡来设置透明度
AnimPosition Vector 位置,设置此将会使用动画过渡来设置位置
SocketName string 插槽名称
SecondName string 次级名称
LabelColor Color 文本颜色
StripeBackgroundColor Color 背景条纹颜色
ContentSize float content的大小(宽高一比一)
IsEmpty bool 是否是空,空则显示插槽图标,反之显示内容图标
ContentImage Texture2D 内容图标
SocketImage Texture2D 插槽图标
LineColor Color 线条颜色(如果有指示器则是指示器的数值)
LineWidth float 线条宽度(如果有指示器则是指示器的数值)

注意其中的LineColorLineWidthOpacityAnimOpacityAnimPosition属性,如果父级为指示线,则其将会与指示线数值双向同步,否则将会使用默认值,因此我们需要检测插槽是否被安置在指示器上,通过监听AttachToPanelEvent回调来判断是否被安插到了指示器上。

//声明一个私有变量_parent,指示器(如果没有指示器则为null)
private IndicatorElement _parent;
//创建一个工具函数RegisterCallbacks,在这里面进行注册回调。在构造函数中调用此函数
private void RegisterCallbacks(){
    //监听DetachFromPanelEvent,当插槽被从面板上分离时,将_parent设置为null
    RegisterCallback<DetachFromPanelEvent>(_ =>{
        _parent = null;
    });
    //监听AttachToPanelEvent,当插槽被安插到面板上时,获取其父元素,若其父元素是IndicatorElement类型,则将其赋值给_parent
    RegisterCallback<AttachToPanelEvent>(_ =>{
        _parent = parent as IndicatorElement;
    });
}

添加一个带参构造函数,提供一个指示线元素类型的参数,用于允许在构造时直接指定父级:

public SocketElement(IndicatorElement parent):this()
{
    _parent = parent;
    //下文将出现_opacity 的定义,为节省篇幅此处直接使用
    _opacity = _parent.style.opacity.value;
}

接下来通过对_parent的判断,我们就可以实现插槽指示线的属性联动了:

#region Properties
    private Color _stripeBackgroundColor;
    private Color _labelColor;
    private float _contentSize;
    private float _opacity = 1.0f;
    private bool _isEmpty;
    private bool _hideSocket;
    private Color _lineColor = Color.white;
    private float _lineWidth = 1;
    
    //透明度,与指示线联动
    public float Opacity{
        get => _opacity;
        set{
            _opacity = value;
            if (_parent != null)
                _parent.style.opacity = value;
            else
                style.opacity = value;
        }
    }
    //线条颜色,与指示线联动
    public Color LineColor{
        get => _parent?.LineColor ?? _lineColor;
        set{
            if (_parent != null)
                _parent.LineColor = value;
            else
                _lineColor = value;
            _content.MarkDirtyRepaint();
        }
    }
    //线条宽度,与指示线联动
    public float LineWidth{
        get => _parent?.LineWidth ?? _lineWidth;
        set{
            if (_parent != null)
                _parent.LineWidth = value;
            else
                _lineWidth = value;
            _content.MarkDirtyRepaint();
        }
    }
    
    public string SocketName{
        get => _label.text;
        set => _label.text= value;
    }
    public string SecondName{
        get => _secondLabel.text;
        set{
            _secondLabel.text= value;
            _secondLabelBackground.style.display = value.Length == 0 ? DisplayStyle.None : DisplayStyle.Flex;
        }
    }
    public Color LabelColor{
        get => _labelColor;
        set{
            _labelColor = value;
            _label.style.color = value;
            _labelBackground.style.backgroundColor = new Color(1.0f - value.r, 1.0f - value.g, 1.0f - value.b, value.a);
            _secondLabel.style.color = value;
            _secondLabelBackground.style.backgroundColor = _labelBackground.style.backgroundColor;
        }
    }
    public Color StripeBackgroundColor{
        get => _stripeBackgroundColor;
        set{
            _stripeBackgroundColor = value;
            _stripeBackground.BaseColor = value;
        }
    }
    public float ContentSize{
        get => _contentSize;
        set{
            _contentSize = value;
            _content.style.width = value;
            _content.style.height = value;
        }
    }
    public bool IsEmpty{
        get => _isEmpty;
        set{
            _isEmpty = value;
            _stripeBackground.Visible = value;
            _content.style.backgroundColor = value ? Color.clear : Color.black;
        }
    }
    public Texture2D ContentImage{
        set => _contentImage.image = value;
    }
    public Texture2D SocketImage{
        set => _socketImage.image = value;
    }
#endregion

属性AnimOpacityAnimPositionHideSocket用到了experimental.animation来进行动画差值,将会在下一节中进行介绍。

experimental.animation实现可控动画插值

⚠️此功能是实验性功能,未来可能会有所变化。

使用experimental.animation来实现动画差值能够监听多种回调,从而做到更精确的控制。也能够直接进行纯数学插值回调(实现类似于DOTween的高级动画插值)。

若无需精确控制,则只需要使用style.transitionPropertystyle.transitionDelaystyle.transitionDurationstyle.transitionTimingFunction对某一个属性设置自动插值(核心思想与HTML编程一致)。

例如,实现对width进行自动插值,则使用代码进行设置的方式为:

_picture.style.transitionProperty = new List<StylePropertyName> { "width" };
_picture.style.transitionDuration =  new List<TimeValue> { 0.5f};
_picture.style.transitionDelay = new List<TimeValue> { 0f}; //默认为0,可以忽略
_picture.style.transitionTimingFunction = new List<EasingFunction> { EasingMode.Ease }; //默认为Ease,可以忽略

使用experimental.animation.Start(StyleValues to, int durationMs)即可完成对style属性手动调用动画插值,起始值为当前style值,使用方法为:

    //隐藏插槽
    public bool HideSocket{
        get => _hideSocket;
        set{
            _hideSocket = value;
            var to = new StyleValues{
                opacity = value ? 0 : 1
            };
            experimental.animation.Start(to, 1000);
        }
    }
    //透明度,与指示器联动
    public float AnimOpacity{
        get => _opacity;
        set{
            if(Math.Abs(_opacity - value) < 0.01f)return;
            _opacity = value;
            var to = new StyleValues{
                opacity = value
            };
            if (_parent != null)
                _parent.experimental.animation.Start(to, 500);
            else
                experimental.animation.Start(to, 500);
        }
    }
    //位置,与指示器联动
    public Vector2 AnimPosition
    {
        set{
            var to = new StyleValues{
                left = value.x,
                top = value.y
            };
            if (_parent != null)
                _parent.experimental.animation.Start(to, 500);
            else
                experimental.animation.Start(to, 500);
        }
    }

对于更高级的用法,我将给出一个例子用于实现无限运动的背景动画。

实现无限滚动的背景动画

背景动画原理如图所示,通过生成条纹矢量图,使用动画控制其平移循环,从而实现无限滚动的视觉效果。
在这里插入图片描述

目前UI Toolkit尚不支持Shader

通过保存experimental.animation.Start的返回值,来确保始终只有一个动画实例正在播放。
绑定播放结束回调,触发再次播放动画的动作。令播放时间曲线为线性,从而实现无缝的动画衔接。

public class VectorImageElement : VisualElement
{
    private VectorImage _vectorImage;
    private float _offset;
    private Color _currentColor;
    private bool _visible = true;
    private Color _baseColor = Color.black * 0.5f;
    
    public Color BaseColor{
        get => _baseColor;
        set{
            _baseColor = value;
            if(_currentColor != _baseColor)//颜色有变化,重新创建矢量图
                CreateVectorImage(BaseColor);
        }
    }
    public bool Visible{
        get => _visible;
        set{
            _visible = value;
            if (value){
                style.display = DisplayStyle.Flex;
                AnimationScroll();
            }
            else{
                style.display = DisplayStyle.None;
            }
        }
    }
    
    //无限循环动画逻辑
    private ValueAnimation<float> _animation;
    private void AnimationScroll(){
        if(_animation is { isRunning: true })return;
        if(!Visible)return;
        _animation = experimental.animation.Start(0f, 14.14f, 2000, (_, f) =>{
            _offset = f;
            MarkDirtyRepaint();//每一次移动后,刷新界面
        }).Ease(Easing.Linear);
        _animation.onAnimationCompleted = AnimationScroll;
    }

    public VectorImageElement(){
        style.position = Position.Absolute;
        style.top = 0;
        style.left = 0;
        style.right = 0;
        style.bottom = 0;
        
        generateVisualContent += OnGenerateVisualContent;
        CreateVectorImage(BaseColor);
        AnimationScroll();
    }
    //生成倾斜的矢量图
    private void CreateVectorImage(Color color){
        var p = new Painter2D();
        p.lineWidth = 5;
        p.strokeColor = color;
        _currentColor = color;
        var begin = new Vector2(0,0);
        var end = new Vector2(141.4f,0);
        for (var i = 0; i <= 120 * 1.414f; i+= 10){
            p.BeginPath();
            p.MoveTo(begin);
            p.LineTo(end);
            p.Stroke();
            p.ClosePath();
            begin.y = i;
            end.y = i;
        }
        var tempVectorImage = ScriptableObject.CreateInstance<VectorImage>();
        if (p.SaveToVectorImage(tempVectorImage)){
            Object.DestroyImmediate(_vectorImage);
            _vectorImage = tempVectorImage;
        }
        p.Dispose();
    }
    //绘制偏移的矢量图
    private void OnGenerateVisualContent(MeshGenerationContext mgc){
        var r = contentRect;
        if (r.width < 0.01f || r.height < 0.01f)
            return;
        
        var scale = new Vector2(r.width / 100,r.height / 100);
        var offset = new Vector2(50 + _offset, -50 - _offset) * scale;
        mgc.DrawVectorImage(_vectorImage,offset,Angle.Degrees(45f),scale);
    }
}

ℹ️虽然目前UI Toolkit未提供Shader接口,但UI Toolkit确实提供了一种可以实现自定义即时模式渲染的元素,称为ImmediateModeElement,通过重写ImmediateRepaint方法,在其中通过即时图形APIGraphics.DrawTextureGraphics.DrawMesh等实现绘制。
但经过测试其Draw存在诡异的Y轴偏移(约20px),因此此处未使用Shader实现。此discussions提到了此问题,但没有解决方案

其中1.414是 √2的数值,因为content的宽高比率为1:1。则斜对角的长度比率为1:√2。

⚠️注意:DrawVectorImage是一个非常耗时的操作,请仅在鼠标悬停时渲染动画背景。

完善UxmlTraits与自绘逻辑

现在插槽的所有属性均已实现,可以完成剩余的部分。
UxmlTraits完整内容:

public class SocketElementUxmlTraits : VisualElement.UxmlTraits{
    public override void Init(VisualElement ve, IUxmlAttributes bag, CreationContext cc){
        var se = (SocketElement)ve;
        se.HideSocket = _hideSocket.GetValueFromBag(bag, cc);
        se.LabelColor = _labelColor.GetValueFromBag(bag, cc);
        se.ContentSize = _contentSize.GetValueFromBag(bag, cc);
        se.StripeBackgroundColor = _stripeBackgroundColor.GetValueFromBag(bag, cc);
        se.IsEmpty = _isEmpty.GetValueFromBag(bag, cc);
        se.SocketName = _socketName.GetValueFromBag(bag, cc);
        se.SecondName = _secondName.GetValueFromBag(bag, cc);
        base.Init(ve, bag, cc);
    }
    private readonly UxmlBoolAttributeDescription _hideSocket = new(){
        name = "hide-socket",
        defaultValue = false
    };
    private readonly UxmlFloatAttributeDescription _contentSize = new(){
        name = "content-size",
        defaultValue = 100f
    };
    
    private readonly UxmlColorAttributeDescription _labelColor = new(){
        name = "label-color",
        defaultValue = Color.black
    };            
    private readonly UxmlColorAttributeDescription _stripeBackgroundColor = new(){
        name = "stripe-background-color",
        defaultValue = Color.black * 0.5f
    };
    private readonly UxmlBoolAttributeDescription _isEmpty = new(){
        name = "is-empty",
        defaultValue = true
    };
    private readonly UxmlStringAttributeDescription _socketName = new(){
        name = "socket-name",
        defaultValue = ""
    };
    private readonly UxmlStringAttributeDescription _secondName = new(){
        name = "second-name",
        defaultValue = ""
    };
}

Texture2D无法通过UxmlAttributeDescription分配,因此无需为其指定。

⚠️再次强调:name属性需要与C#属性对应。

generateVisualContent委托:

public SocketElement(){
	...忽略其他代码...
     _content.generateVisualContent += DrawBorder;
    RegisterCallbacks();
}

private void DrawBorder(MeshGenerationContext mgc)
{
    var painter = mgc.painter2D;
    var element = mgc.visualElement;
    painter.lineWidth = LineWidth;
    painter.strokeColor = LineColor;
    //线段的端点使用圆形形状
    painter.lineCap = LineCap.Round;
    //绘制边框
    var width = element.style.width.value.value;
    var height = element.style.height.value.value;
    painter.BeginPath();
    painter.MoveTo(new Vector2(0,height * 0.2f));
    painter.LineTo(Vector2.zero);
    painter.LineTo(new Vector2(width,0));
    painter.LineTo(new Vector2(width,height * 0.2f));
    painter.Stroke();
    
    painter.BeginPath();
    painter.MoveTo(new Vector2(0,height * 0.8f));
    painter.LineTo(new Vector2(0,height));
    painter.LineTo(new Vector2(width,height));
    painter.LineTo(new Vector2(width,height * 0.8f));
    painter.Stroke();
}

鼠标响应事件

更新RegisterCallbacks工具函数,添加对鼠标进入事件PointerEnterEvent、鼠标移出事件PointerLeaveEvent的侦听处理。修改相关的外观属性,让UI更具交互性。

private void RegisterCallbacks(){
    // 原始样式样式
    var oldColor = Color.white;
    var oldWidth = 1f;
    _content.RegisterCallback<PointerEnterEvent>(_ =>{
        _button.style.backgroundColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.5f));
        oldColor = LineColor;
        oldWidth = LineWidth;
        LineColor = Color.white;
        LineWidth = 5;
        _content.MarkDirtyRepaint();
    });
    
    _content.RegisterCallback<PointerLeaveEvent>(_ => {
        _button.style.backgroundColor = new StyleColor(Color.clear);
        LineColor = oldColor;
        LineWidth = oldWidth;
        _content.MarkDirtyRepaint();
    });
    RegisterCallback<DetachFromPanelEvent>(_ =>{
        _parent = null;
    });
    RegisterCallback<AttachToPanelEvent>(_ =>{
        _parent = parent as IndicatorElement;
    });
}

⚠️注意:
我们使用了_contextgenerateVisualContent委托,因此需要调用_contextMarkDirtyRepaint

动态字符标签 DynamicLabelElement

ℹ️本节使用了schedule进行周期性调用某个函数,以实现预计功能

本项目的所有Label均以平替为DynamicLabelElement,用于显示动感的逐字浮现特效,例如上文的SocketElement
在这里插入图片描述
其核心逻辑是,当接受到设置目标字符串消息时,立刻以固定时间间隔对目标字符串逐字符进行处理:

  • 对当前位置的字符(为当前字符串长度小于当前位置则附加,否则替换)随机的选择一个字符显示,重复n次。
  • 显示正确的字符。
  • 开始处理下一个字符。

与上文不同的是,虽然其确实需要一种时间回调来触发字符回显,但是其使用schedule而不是experimental.animation实现逻辑。
通过schedule启动一个固定时间间隔的回调,在回调中进行处理。

DynamicLabelElement继承自TextElement
DynamicLabelElement需要UxmlTraits

属性设计

变量名 类型 作用
TargetText string 目标字符串
RandomTimes int 每个字符随机显示其他字符的次数
DeleteSpeed float 删除字符的速度(秒)
RevealSpeed float 回显到正确字符的速度(秒)
DeleteBeforeReveal bool 开始之前清空当前字符串
SkipSameChar bool 跳过相同字符(如果当前字符与目标相同则直接处理下一个字符)

UxmlFactory & UxmlTraits

public class DynamicLabelUxmlFactory : UxmlFactory<DynamicLabelElement, DynamicLabelUxmlTraits> { }

public class DynamicLabelUxmlTraits : TextElement.UxmlTraits{
    public override void Init(VisualElement ve, IUxmlAttributes bag, CreationContext cc){
        var se = (DynamicLabelElement)ve;
        se.RandomTimes = _randomTimes.GetValueFromBag(bag, cc);
        se.DeleteBeforeReveal = _deleteBeforeReveal.GetValueFromBag(bag, cc);
        se.DeleteSpeed = _deleteSpeed.GetValueFromBag(bag, cc);
        se.SkipSameChar = _skipSameChar.GetValueFromBag(bag, cc);
        se.RevealSpeed = _revealSpeed.GetValueFromBag(bag, cc);
        se.TargetText = _targetText.GetValueFromBag(bag, cc);
        base.Init(ve, bag, cc);
    }
    private readonly UxmlStringAttributeDescription _targetText = new(){
        name = "target-text",
        defaultValue = "DynamicText"
    };
    private readonly UxmlIntAttributeDescription _randomTimes = new(){
        name = "random-times",
        defaultValue = 5
    };
    private readonly UxmlBoolAttributeDescription _deleteBeforeReveal = new(){
        name = "delete-before-reveal",
        defaultValue = true
    };
    private readonly UxmlFloatAttributeDescription _deleteSpeed = new(){
        name = "delete-speed",
        defaultValue = 0.1f,
    };
    private readonly UxmlBoolAttributeDescription _skipSameChar = new(){
        name = "skip-same-char",
        defaultValue = false
    };
    private readonly UxmlFloatAttributeDescription _revealSpeed = new(){
        name = "reveal-speed",
        defaultValue = 0.1f,
    };
}

总体逻辑

public class DynamicLabelElement : TextElement
{
    private string _targetText = ""; // 动画目标文本
    private int _randomTimes = 3;//随机次数
    private int _currentIndex; // 当前字符索引
    private int _currentRandomIndex;// 当前随机字符次数
    private bool _deleteBeforeReveal;// 回显前清空
    private float _deleteSpeed = 0.01f; // 删除每个字符的速度
    private float _revealSpeed = 0.01f; // 显示每个字符的速度
    private bool _isAnimating; // 标识是否正在执行动画
    private readonly StringBuilder _tempTextBuilder = new(); // 临时文本构建器
    private IVisualElementScheduledItem _animationScheduler;
    private const string RandomChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; // 随机字符集
    //C#属性
    public string TargetText{
        get => _targetText;
        set{
            if(_targetText.Equals(value))return;
            _targetText = value;
            AnimateTextTransition(_targetText);
        }
    }
    public int RandomTimes{
        get => _randomTimes;
        set => _randomTimes = Mathf.Clamp(value, 0, 10);
    }
    public float DeleteSpeed{
        get => _deleteSpeed;
        set => _deleteSpeed = Mathf.Clamp(value, 0.01f, float.MaxValue);
    }
    public float RevealSpeed{
        get => _revealSpeed;
        set => _revealSpeed= Mathf.Clamp(value, 0.01f, float.MaxValue);
    }
    public bool DeleteBeforeReveal{
        get => _deleteBeforeReveal;
        set => _deleteBeforeReveal = value;
    }
    public bool SkipSameChar { get; set; }
    
    //构造函数
    public DynamicLabelElement() : this(string.Empty) { }
    
    public DynamicLabelElement(string txt){
        AddToClassList("dynamic-label");
        enableRichText = false;
        TargetText = txt;
    }

    // 设置新的目标文本
    private void AnimateTextTransition(string newText){
        // 设置新的目标文本和动画速度
        _targetText = newText;
        // 如果正在执行动画,无需再次启动
        if (_isAnimating) return;
        // 启动新的动画
        _isAnimating = true;
        StartDeleting();
    }

    // 开始删除文本
    private void StartDeleting(){
    	//无需删除 直接回显
        if (!_deleteBeforeReveal){
            StartRevealing();
            return;
        }
        _tempTextBuilder.Clear();
        _animationScheduler?.Pause();
        _animationScheduler = schedule.Execute(DeleteCharacter).Every((long)(DeleteSpeed * 1000)).StartingIn(0);
    }

    // 删除字符的逻辑
    private void DeleteCharacter(){
        if (text.Length > 0)
            text = text[..^1];
        else// 删除完成后,开始显示目标文本
            StartRevealing();
    }

    // 开始显示新文本
    private void StartRevealing(){
        _animationScheduler?.Pause();
        _currentIndex = 0; // 重置显示索引
        _currentRandomIndex = 0;
        _tempTextBuilder.Clear();
        _tempTextBuilder.Append(text);
        _animationScheduler = schedule.Execute(RevealCharacter).Every((long)(RevealSpeed / (_randomTimes + 1) * 1000)).StartingIn(0);
    }

    // 显示字符的逻辑
    private void RevealCharacter(){
        if (_currentRandomIndex == 0 && SkipSameChar){
            while (_currentIndex < _tempTextBuilder.Length && 
                   _currentIndex < _targetText.Length && 
                   _tempTextBuilder[_currentIndex] == _targetText[_currentIndex])
                _currentIndex++;
        }
        
        if (_currentIndex < _targetText.Length){
            char targetChar;
            var finished = false;
            if (_currentRandomIndex < RandomTimes){
                targetChar = RandomChars[Random.Range(0, RandomChars.Length)];
                _currentRandomIndex++;
            }
            else{
                targetChar  = _targetText[_currentIndex];
                _currentRandomIndex = 0;
                finished = true;
            }
            
            if (_currentIndex == _tempTextBuilder.Length)
                _tempTextBuilder.Append(targetChar);
            else
                _tempTextBuilder[_currentIndex] = targetChar;
            if (finished)
                _currentIndex++;
            text = _tempTextBuilder.ToString();
        }
        else
        {
            if (_currentIndex < _tempTextBuilder.Length){
                if (_currentRandomIndex < _randomTimes){
                    _currentRandomIndex++;
                    return;
                }
                _currentRandomIndex = 0;
                _tempTextBuilder.Remove(_currentIndex, 1);
                text = _tempTextBuilder.ToString();
                return;
            }
            // 显示完成,停止动画
            _isAnimating = false;
            CancelCurrentAnimation();
        }
    }
    // 取消当前的动画
    private void CancelCurrentAnimation(){
        // 清除定时器
        _animationScheduler.Pause();
        _animationScheduler = null;
        // 重置状态
        _isAnimating = false;
    }
}

核心逻辑为RevealCharacter,通过schedule.Execute(RevealCharacter).Every((long)(RevealSpeed / (_randomTimes + 1) * 1000)),固定周期调用RevealCharacter
逻辑较为简单,因此不过多赘述。现在可将项目中所有Label替换为DynamicLabelElement

⚠️警告:UxmlTraits中规定的初始值只对UI Builder中的元素起作用。由代码构建的元素无法读取UxmlTraits中的初始值,需要在代码中明确指定初始值,例如:

_label = new DynamicLabelElement("")
{
   style =
   {
       marginBottom = 0,marginLeft = 0,marginRight = 0,marginTop = 0,
       paddingBottom = 0,paddingTop = 0,
       unityFontStyleAndWeight = FontStyle.Bold
   },
   name = "socket-name",
   RevealSpeed = 0.1f, //<===注意:给定初始值
   RandomTimes = 5, //<===注意:给定初始值
}

部件指示器UI更新逻辑

ℹ️使用两种算法:椭圆映射力导向算法
ℹ️使用Unity Job加速计算
⚠️限于篇幅原因,结构经过简化,以避免涉及武器组件相关逻辑。因此指示线数量是静态的。

为了统一管理指示线相关的逻辑,建立一个IndicatorManager

指示线管理器 IndicatorManager

建立一个类型结构,用于存储IndicatorElement,称其为Socket

public class Socket{
    public Transform slotTransform; //指示线要指向的目标
    public string socketName; //插槽名称
    [NonSerialized] public float Angle;
    [NonSerialized] public Vector3 OriginalPos;
    [NonSerialized] public IndicatorElement IndicatorElement;
    [NonSerialized] public SocketElement SocketElement;
}

之后在IndicatorManager中,声明列表,存储所有要展示的Socket:

[SerializeField] private List<Socket> Sockets; //需要在Unity Editor中指定

private UIDocument _uiDocument; //UI布局
private VisualElement _rootElement;//根节点,将各种元素添加到此元素之下
private static IndicatorManager _instance;//单例

在Start中初始化这些数据

private void Start()
{
    _instance = this;
    _uiDocument = GetComponent<UIDocument>();
    _rootElement = _uiDocument.rootVisualElement.Q<VisualElement>("interactiveLayer");

	InitializeSocketArray();
}

其中InitializeSocketArray用于初始化SockeIndicatorElementSocketElement

private void InitializeSocketArray(){
//Socket已经在Unity Editor中分配了slotTransform、socketName
	foreach (var socket in Sockets){
		var indicatorElement = new IndicatorElement {
		    LineColor = Color.white,
		    LineWidth = 1,
		    BlockSize = 20,
		    style ={
		        top = 100,
		        left = 100,
		        opacity = _soloTimeGap ? 0f : 1.0f,//在solo时间间隙之前,屏幕上不能出现任何组件
		    }
		};
		var socketElement = new SocketElement(indicatorElement){
		    SocketName = socket.socketName,
		    LabelColor = Color.black,
		};
		indicatorElement.Add(socketElement);
		// socket.SocketElement.BindClickEvent(); //绑定事件
		_rootElement.Add(indicatorElement);
		socket.IndicatorElement = indicatorElement;
		socket.SocketElement = socketElement;
	}
}

⚠️:需在UnityEditor中手动分配List<Socket>的部分数据(transformsocketName)。

更新指示线让其总指向场景物体

ℹ️通过对IndicatorElement列表进行遍历,使用Camera.WorldToScreenPoint方法计算屏幕坐标位置。更新对应IndicatorElement组件。

建立一个函数TransformWorldToScreenCoordinates

private void TransformWorldToScreenCoordinates()
{
    foreach (var socket in Sockets)
    {
        var worldToScreenPoint = (Vector2)_mainCamera.WorldToScreenPoint(socket.slotTransform.position);
        socket.OriginalPos = worldToScreenPoint;
        worldToScreenPoint.y = Screen.height - worldToScreenPoint.y; //颠倒Y轴
        socket.IndicatorElement.TargetPoint = worldToScreenPoint;
    }
}

其中,由于Unity的Screen坐标轴与GUI坐标轴原点不同(Screen坐标系原点位于左下角),需要进行一次减法,颠倒Y轴。
之后在Update中,每一帧都调用此函数即可:

private void Update(){
	//更新指示线目标
	TransformWorldToScreenCoordinates();
}

目前的实现效果如下,指示线的目标将会始终指向在Socket列表中分配的transform物体。(图中四个角落为一个空物体)
在这里插入图片描述

更新插槽让其沿着椭圆排布

首先插槽的位置必定是由算法自动控制而非人工预指定,根据其他游戏中的表现,可以看出这些插槽大致是从中心点散发,沿着椭圆的形状排布:
在这里插入图片描述

首先介绍核心逻辑,假设对于四个点,我们需要将其映射到椭圆上,则最基本的步骤应该如下:

step1 step2 step3
在这里插入图片描述 在这里插入图片描述 在这里插入图片描述
红色为圆心 从圆心向四周发射射线 射线与椭圆交点为预计位置

为了计算这个过程,我们需要首先计算射线与水平的夹角,进而通过夹角计算射线交点坐标:
在这里插入图片描述
如何计算某个水平角度射线与椭圆的交点?涉及到高中数学知识,下面我直接给出解法:

/// <summary>
/// 计算椭圆上的顶点位置:<br/>
/// 在椭圆原点上,夹角为 angleDeg 度的射线,交于椭圆上的位置
/// </summary>
/// <param name="angleDeg">角度 (与Vector.right形成的有符号夹角)</param>
/// <returns>椭圆上的位置</returns>
private static Vector2 CalculateEllipseIntersection(float angleDeg)
{
    var theta = angleDeg * Mathf.Deg2Rad; // 角度转换为弧度
    var cosTheta = Mathf.Cos(theta);
    var sinTheta = Mathf.Sin(theta);

    // 计算二次方程系数
    var a = (cosTheta * cosTheta) / (EllipticalA * EllipticalA) + (sinTheta * sinTheta) / (EllipticalB * EllipticalB);

    // 解二次方程
    var discriminant = 4 * a;
    if (discriminant < 0) return Vector2.zero;
    
    var sqrtDiscriminant = Mathf.Sqrt(discriminant);
    var t = Mathf.Abs(sqrtDiscriminant / (2 * a));
    return new Vector2(t * cosTheta, t * sinTheta) + EllipticalCenter;
}

注意其中的ellipticalCenterellipticalAellipticalAB,是全局的变量:

public Vector2 ellipticalCenter; // 椭圆的中心位置
public float ellipticalA = 300f; // 椭圆的长轴半径
public float ellipticalB = 100f; // 椭圆的短轴半径

DebugDraw绘制目标椭圆

为了能够直观的看出椭圆情况,我们可以考虑在屏幕上画出椭圆,绘制椭圆用到了上文所建立的CalculateEllipseIntersection函数:

private void DrawEllipse(){
    Vector3 previousPoint = CalculateEllipseIntersection(0); // 第一个点
    var pointZ = _mainCamera.nearClipPlane + 0.01f;
    previousPoint.z = pointZ;
    previousPoint.y = Screen.height - previousPoint.y;
    for (var i = 1; i <= 50; i++){
        var angle = 360 * i / 50; // 每个分段的角度
        Vector3 currentPoint = CalculateEllipseIntersection(angle);
        currentPoint.y = Screen.height - currentPoint.y;
        currentPoint.z = pointZ;
        // 将屏幕坐标转换到世界坐标并绘制线段
        Debug.DrawLine(_mainCamera.ScreenToWorldPoint(previousPoint), _mainCamera.ScreenToWorldPoint(currentPoint), Color.green);
        previousPoint = currentPoint; // 更新前一点为当前点
    }
}

其中_mainCameraCamera.main,请自行建立全局变量。
LateUpdate中进行绘制:

private void LateUpdate(){
#if DEBUG_DRAW
	DrawEllipse();
#endif
}

可以考虑在Update中,每帧更新椭圆的中心位置。
在这里插入图片描述

⚠️:查看Debug.Draw的绘制结果需要打开Gizmos可见性。

排布插槽到椭圆

回到本节开头所阐述的内容,问题的关键是需要得知角度:
在这里插入图片描述
建立一个函数TransformSocketToEllipse用于映射插槽到椭圆中:

private void TransformSocketToEllipse()
{
    foreach (var socket in Sockets)
    {
    	//这里我们直接使用OriginalPos,OriginalPos的值在TransformWorldToScreenCoordinates中设置了。
        var worldToScreenPoint = socket.OriginalPos;
        var direction = (Vector2)worldToScreenPoint - EllipticalCenter;
        socket.Angle = Vector2.SignedAngle(direction, Vector2.right);
    }
    //此时 Angle即为α角度
    foreach (var socket in Sockets)
    {
        var socketElementEndPoint = CalculateEllipseIntersection(socket.Angle);
        socket.IndicatorElement.Position = socketElementEndPoint;                
    }
}

在第一个foreach循环中,通过使用TransformWorldToScreenCoordinates中计算的屏幕坐标OriginalPos,来计算水平线与圆心-插槽的角度α
在第二个foreach循环中,我们使用了α来计算射线与椭圆交点作为插槽位置。
在这里我们没有将其整合在同一个foreach中,因为α需要进行处理,原因可从目前的效果中看出:
在这里插入图片描述
插槽之间过于自由,以至于会出现相互重叠的情况。
重叠

力导向分散算法

所谓力导向,即循环遍历所有位置,检查任意两个位置之间的距离是否过近,如果过近则将此两个位置相互远离一段距离。
在这里不同的是将两个角度的差异增大,例如:
在这里插入图片描述
建立一个函数SpreadSocketsByDistance用于处理这个过程:

private void SpreadSocketsByDistance(List<Socket> socketList, float minDistance, int iterations = 100)
{
    var count = socketList.Count;
    if (count < 2) return;
    //先进行排序
    socketList.Sort((a, b) => a.Angle.CompareTo(b.Angle));
    
    for (var i = 0; i < iterations; i++){
        var adjusted1 = false;
        // 遍历每一对相邻角度
        for (var j = 0; j < count; j++){
            var next = (j + 1) % count; // 循环的下一个角度
            var currentAngle = socketList[j].Angle;
            var nextAngle = socketList[next].Angle;
  
            // 获取两个角度对应的椭圆上的点
            var currentPoint = CalculateEllipseIntersection(currentAngle);
            var nextPoint = CalculateEllipseIntersection(nextAngle);
    
            // 计算两点间的实际距离
            var actualDistance = Vector2.Distance(currentPoint, nextPoint);
            // 如果距离小于最小距离,则施加力调整角度
            if (actualDistance < minDistance){
            	//力值估算
                var force = Mathf.Atan((minDistance - actualDistance) / Vector2.Distance(socketList[j].OriginalPos, currentPoint)) * Mathf.Rad2Deg * rate;
                
                socketList[j].Angle -= force ;
                socketList[next].Angle += force ;
                adjusted1 = true;
            }
        }
        // 如果没有任何调整,提早退出迭代
        if (!adjusted1) break;
    }
}

其中最重要的语句为(其中rate为全局变量,下文中有声明):

var force = Mathf.Atan((minDistance - actualDistance) / Vector2.Distance(socketList[j].OriginalPos, currentPoint)) * Mathf.Rad2Deg * rate;

此句决定了此力导向算法的效率,影响稳定性、迭代次数。
越小的force导致更多的迭代次数,越大的force会增大不确定性(在不同的位置不停闪现)。
原句中代码的force大小依赖于模糊计算的差距值角度β:
在这里插入图片描述

ℹ️:此方式是我个人的观点,不能保证一定是最佳的效果,你可以使用其他的计算方式。

在开始循环前,我使用Sort进行排序,从而在后续循环中,只比较最临近的两个元素,而不是进行列表循环逐一比较。

  • 优点:减少了时间复杂度(减少一层循环)。
  • 缺点:必须要确保force值要尽可能的小,防止打乱数组中Angle的大小顺序。否则会导致重叠(虽然数组索引临近的元素与自身保持了最小距离,但非索引相邻的元素未保持最小距离)

    这种情况表现为(同色点为相邻点,过大的force导致Angle顺序被打乱,临近比较变得无效):
    在这里插入图片描述

使用力导向算法

为了使用此函数,添加全局变量:

public float minDistance = 100; //最小距离
[Range(0, 1)] public float rate;//调整比率

调整TransformSocketToEllipse函数中的代码,在foreach循环之间调用此函数:

private void TransformSocketToEllipse()
{
    foreach (var socket in Sockets)
    {
    	//这里我们直接使用OriginalPos,OriginalPos的值在TransformWorldToScreenCoordinates中设置了。
        var worldToScreenPoint = socket.OriginalPos;
        var direction = (Vector2)worldToScreenPoint - EllipticalCenter;
        socket.Angle = Vector2.SignedAngle(direction, Vector2.right);
    }
    //此时 Angle即为α角度
    SpreadSocketsByDistance(Sockets, minDistance);
    foreach (var socket in Sockets)
    {
        var socketElementEndPoint = CalculateEllipseIntersection(socket.Angle);
        socket.IndicatorElement.Position = socketElementEndPoint;                
    }
}

下图是rate0调整到0.1的效果:
在这里插入图片描述

预排布

目前存在一个问题,插槽只会根据指示点圆心的相对位置来排布,某些情况下,显得过于局促:
在这里插入图片描述
一个方案是计算指示点中心,从中心向四周扩散,计算交点:
在这里插入图片描述
为了实现此功能,我们需要实现一个新的CalculateEllipseIntersection重载,支持任意位置发出的射线,而不是固定从圆心发出的射线:

private Vector2 CalculateEllipseIntersection(Vector2 origin,float angleDeg)
{
    // 将椭圆中心平移到原点
    var adjustedOrigin = origin - EllipticalCenter;

    var theta = angleDeg * Mathf.Deg2Rad; // 角度转换为弧度
    var cosTheta = Mathf.Cos(theta);
    var sinTheta = Mathf.Sin(theta);

    // 计算二次方程系数
    var squareA = (EllipticalA * EllipticalA);
    var squareB = (EllipticalB * EllipticalB);
    var a = (cosTheta * cosTheta) / squareA + (sinTheta * sinTheta) / squareB;
    var b = 2 * ((adjustedOrigin.x * cosTheta) / squareA + (adjustedOrigin.y * sinTheta) / squareB);
    var c = (adjustedOrigin.x * adjustedOrigin.x) / squareA + (adjustedOrigin.y * adjustedOrigin.y) / squareB - 1;

    // 解二次方程
    var discriminant = squareB - 4 * a * c;
    //Δ<0没有交点
    if (discriminant < 0) return Vector2.zero;
        
    var sqrtDiscriminant = Mathf.Sqrt(discriminant);
    // 求正数解
    var t = Mathf.Abs((-b + sqrtDiscriminant) / (2 * a));
    var intersection = new Vector2(adjustedOrigin.x + t * cosTheta, adjustedOrigin.y + t * sinTheta);
    // 逆向平移回原始位置
    return intersection + EllipticalCenter;
}

该内容实际上就是为圆心添加偏移,其算法与无偏移射线算法完全一致。

之后更新TransformSocketToEllipse,添加一个可选的参数advance,指示使用采用预排布的算法:

private void TransformSocketToEllipse(bool advance = false)
{
    if (advance){
        var center = Sockets.Aggregate(Vector2.zero, (current, socket) => current + (Vector2)socket.OriginalPos);
        center /= Sockets.Count;
        foreach (var socket in Sockets){
            var direction = (Vector2)socket.OriginalPos - center;
            var angle = Vector2.SignedAngle(Vector2.right, direction);
            var pointOnEllipse = CalculateEllipseIntersection(socket.OriginalPos, angle);
            socket.Angle = Vector2.SignedAngle(pointOnEllipse - EllipticalCenter, Vector2.right);
        }
    }
    else{
        foreach (var socket in Sockets){
            var worldToScreenPoint = socket.OriginalPos;
            var direction = (Vector2)worldToScreenPoint - EllipticalCenter;
            socket.Angle = Vector2.SignedAngle(direction, Vector2.right);
        }                
    }
    SpreadSocketsByDistance(Sockets, _instance.minDistance);
    foreach (var socket in Sockets){
        var socketElementEndPoint = CalculateEllipseIntersection(socket.Angle);
        socket.IndicatorElement.Position = socketElementEndPoint;                
    }
}

advance分支中,提前进行了一次CalculateEllipseIntersection,将插槽放置在椭圆上。之后更新Angle使其与当前位置匹配。
运行效果:
在这里插入图片描述

使用Unity Job加速运算

目前所有的运算都集中于主线程中,会导致帧率降低,可以考虑使用Job多线程分担计算压力。

想要建立Job只需实现IJob接口,之后对实例化的Job对象调用Schedule即可开启Job。通过对其返回值持久保存,来判断运行状态、获取计算结果。

当前流程转为Job则应为如下关系:

输入屏幕坐标
输出插槽坐标
开始Job
计算Angle并排列顺序
预排布?
预排布
力导向算法
结束Job

其中每一个圆角矩形都是一个Job。

Job管理器:MapScreenToEllipseJob

为了便于管理Job,建立一个类MapScreenToEllipseJob用于负责分配任务和获取结果。
之后在这个class(非MonoBehaviour)中实现所有的Job。

ℹ️下面内容都位于MapScreenToEllipseJob类中

计算Angle并排列顺序Job

首先是进行计算Angle并排列顺序的Job:

⚠️:我们仅传入坐标数组,因此需要保证输出坐标数组输入坐标数组的顺序应一致。

建立一个结构,用于存储原始的顺序:

private struct IndexAngle {
    public int OriginalIndex;  // 表示排序前的位置
    public float Value;        // 要排序的数值
}

Job:

[BurstCompile]
//计算Angle:输入屏幕坐标。输出排序后的角度及对应关系
private struct CalculateAngleJob : IJob
{
    /// <b>【输入】</b> 输入的原始屏幕坐标 (无需颠倒Y轴)
    [ReadOnly] private NativeArray<Vector2> _screenPoints;
    /// <b>【输出】</b> 排序后的角度列表对应的原始序号 
    [WriteOnly] public NativeArray<int> IndexArray;
    /// <b>【输出】</b> 预计顶点位于椭圆上的角度列表(排序后) 
    [WriteOnly] public NativeArray<float> AngleArray;
    /// <b>【输入】</b> 椭圆的中心
    [ReadOnly] private readonly Vector2 _ellipticalCenter;
    public CalculateAngleJob(NativeArray<Vector2> screenPoints, Vector2 ellipticalCenter) : this()
    {
        _screenPoints = screenPoints;
        _ellipticalCenter = ellipticalCenter;
    }
    public void Execute()
    {
        var data = new NativeArray<IndexAngle>(_screenPoints.Length, Allocator.Temp);
        for (var index = 0; index < _screenPoints.Length; index++)
        {
            data[index] = new IndexAngle()
            {
                Value = Vector2.SignedAngle(_screenPoints[index] - _ellipticalCenter, Vector2.right),
                OriginalIndex = index
            };
        }
        // 快速排序
        QuickSort(data, 0, data.Length - 1);
        // 分离各个属性
        for (var index = 0; index < _screenPoints.Length; index++)
        {
            IndexArray[index] = data[index].OriginalIndex;
            AngleArray[index] = data[index].Value;
        }
        data.Dispose();
    }

    private void QuickSort(NativeArray<IndexAngle> array, int low, int high)
    {
        if (low >= high) return;
        var pivotIndex = Partition(array, low, high);
        QuickSort(array, low, pivotIndex - 1);
        QuickSort(array, pivotIndex + 1, high);
    }

    private static int Partition(NativeArray<IndexAngle> array, int low, int high) {
        var pivotValue = array[high].Value;
        var i = low - 1;

        for (var j = low; j < high; j++) {
            if (array[j].Value < pivotValue) {
                i++;
                Swap(array, i, j);
            }
        }
        Swap(array, i + 1, high);
        return i + 1;
    }

    private static void Swap(NativeArray<IndexAngle> array, int indexA, int indexB) {
        (array[indexA], array[indexB]) = (array[indexB], array[indexA]);
    }
}

注意其中的WriteOnlyReadOnly标识,若无标识,则表示变量是可读可写的,合理运用标识可提高编译后代码的执行效率。
使用BurstCompile标识可启用Burst编译,大幅提高执行效率,开发时可先不开启,因为这会导致无法有效进行Debug。

预排布Job

Job:

[BurstCompile]
//进阶先行步骤: 以所有坐标的平均中心为原点,向外扩散重映射屏幕坐标到椭圆之上
private struct AdvanceMapPointsJob : IJob
{
    /// <b>【输入/输出】</b> 输入的原始屏幕坐标 (无需颠倒Y轴)
    private NativeArray<Vector2> _screenPoints;
    /// <b>【输入】</b> 椭圆的中心
    [ReadOnly] private readonly Vector2 _ellipticalCenter;
    /// <b>【输入】</b> 椭圆A轴
    [ReadOnly] private readonly float _ellipticalA;
    /// <b>【输入】</b> 椭圆B轴
    [ReadOnly] private readonly float _ellipticalB;
    public AdvanceMapPointsJob(NativeArray<Vector2> screenPoints, Vector2 ellipticalCenter,
        float ellipticalA, float ellipticalB)
    {
        _screenPoints = screenPoints;
        _ellipticalCenter = ellipticalCenter;
        _ellipticalA = ellipticalA;
        _ellipticalB = ellipticalB;
    }
    public void Execute()
    {
        //计算中心点
        var center = Vector2.zero;
        for (var index = 0; index < _screenPoints.Length; index++)
            center += _screenPoints[index];
        center /= _screenPoints.Length;

        
        //将点映射到椭圆上
        for (var index = 0; index < _screenPoints.Length; index++)
        {
            var angle = Vector2.SignedAngle(Vector2.right, _screenPoints[index] - center);
            _screenPoints[index] = CalculateEllipseIntersection(_screenPoints[index], angle);
        }
    }
    private Vector2 CalculateEllipseIntersection(Vector2 origin,float angle)
    {
        // 将椭圆中心平移到原点
        var adjustedOrigin = origin - _ellipticalCenter;

        var theta = angle * Mathf.Deg2Rad; // 角度转换为弧度
        var cosTheta = Mathf.Cos(theta);
        var sinTheta = Mathf.Sin(theta);

        // 计算二次方程系数
        var squareA = (_ellipticalA * _ellipticalA);
        var squareB = (_ellipticalB * _ellipticalB);
        var a = (cosTheta * cosTheta) / squareA + (sinTheta * sinTheta) / squareB;
        var b = 2 * ((adjustedOrigin.x * cosTheta) / squareA + (adjustedOrigin.y * sinTheta) / squareB);
        var c = (adjustedOrigin.x * adjustedOrigin.x) / squareA + (adjustedOrigin.y * adjustedOrigin.y) / squareB - 1;

        // 解二次方程
        var discriminant = squareB - 4 * a * c;
        //Δ<0没有交点
        if (discriminant < 0) return Vector2.zero;
        
        var sqrtDiscriminant = Mathf.Sqrt(discriminant);
        // 求正数解
        var t = Mathf.Abs((-b + sqrtDiscriminant) / (2 * a));
        var intersection = new Vector2(adjustedOrigin.x + t * cosTheta, adjustedOrigin.y + t * sinTheta);
        // 逆向平移回原始位置
        return intersection + _ellipticalCenter;
    }
}

注意:在JobSystem中进行数学运算时,可以考虑使用Mathematics数学计算库代替Mathf,可大幅提高计算效率。

力导向Job
[BurstCompile]
//力导向算法:根据最小距离再分布
private struct SpreadAngleByDistanceJob : IJob
{
    /// <summary>
    /// PointsOnEllipse作为输出
    /// </summary>
    [WriteOnly] public NativeArray<Vector2> PointsOnEllipse;
    [ReadOnly] private NativeArray<int> _indexArray;
    private NativeArray<float> _angleList;
    
    [ReadOnly] private readonly float _minDistanceGap;
    [ReadOnly] private readonly Vector2 _ellipticalCenter;
    [ReadOnly] private readonly float _ellipticalA;
    [ReadOnly] private readonly float _ellipticalB;
    [ReadOnly] private readonly int _iterations;
    [ReadOnly] private readonly float _rate;

    public SpreadAngleByDistanceJob(NativeArray<float> angleList,NativeArray<int> indexArray, float minDistanceGap, Vector2 ellipticalCenter, float ellipticalA, float ellipticalB,int iterations = 100,float rate = 1.0f) : this()
    {
        _angleList = angleList;
        _indexArray = indexArray;
        _minDistanceGap = minDistanceGap;
        _ellipticalCenter = ellipticalCenter;
        _ellipticalA = ellipticalA;
        _ellipticalB = ellipticalB;
        _iterations = iterations;
        _rate = rate;
    }

    public void Execute()
    {
        var count = _angleList.Length;
        if (count > 1) 
            for (var i = 0; i < _iterations; i++)
            {
                var adjusted1 = false;
                // 遍历每一对相邻角度
                for (var j = 0; j < count; j++)
                {
                    var next = (j + 1) % count; // 循环的下一个角度
                    var currentAngle = _angleList[j];
                    var nextAngle = _angleList[next];
    
                    // 获取两个角度对应的椭圆上的点
                    var currentPoint = CalculateEllipseIntersection(currentAngle);
                    var nextPoint = CalculateEllipseIntersection(nextAngle);
                    
                    // 计算两点间的实际距离
                    var actualDistance = Vector2.Distance(currentPoint, nextPoint);
                    // 如果距离小于最小距离,则施加力调整角度
                    if (actualDistance < _minDistanceGap)
                    {
                        var diff = (_minDistanceGap - actualDistance) / Vector2.Distance((currentPoint + nextPoint) / 2,_ellipticalCenter);

                        _angleList[j] -= diff * _rate * 10;
                        _angleList[next] += diff * _rate * 10;

                        adjusted1 = true;
                    }
                }
                
                // 如果没有任何调整,提早退出迭代
                if (!adjusted1) break;
            }

        // 映射椭圆点
        for (var index = 0; index < _angleList.Length; index++)
        {
            var trueIndex = _indexArray[index];
            PointsOnEllipse[trueIndex] = CalculateEllipseIntersection(_angleList[index]);
        }
    }
    
    private Vector2 CalculateEllipseIntersection(float angleDeg)
    {
        var theta = angleDeg * Mathf.Deg2Rad; // 角度转换为弧度
        var cosTheta = Mathf.Cos(theta);
        var sinTheta = Mathf.Sin(theta);

        // 计算二次方程系数
        var a = (cosTheta * cosTheta) / (_ellipticalA * _ellipticalA) + (sinTheta * sinTheta) / (_ellipticalB * _ellipticalB);

        // 解二次方程
        var discriminant = 4 * a;
        if (discriminant < 0)
        {
            return Vector2.zero;
        }

        var sqrtDiscriminant = Mathf.Sqrt(discriminant);
        var t = Mathf.Abs(sqrtDiscriminant / (2 * a));
        return new Vector2(t * cosTheta, t * sinTheta) + _ellipticalCenter;
    }
}

力导向算法中,此处的force计算略有不同,你依然可以采取旧算法

整合Job,分配任务、获取结果

建立两个函数ScheduleJobTryGetResults,以及相关变量,分别用于启动Job获取Job的计算结果

private JobHandle _jobHandle;
private bool _isJobScheduled;
private SpreadAngleJob _mapScreenToEllipseJob;
private NativeArray<Vector2> _results;

private NativeArray<int> _indexArray;
private NativeArray<float> _angleList;
public float minGap = 100;
public int iterations = 100;
public float rate = 0.1f;

public void ScheduleJob(Vector2[] screenPointList,bool advance = false)
{
    if (_isJobScheduled) return;
    _isJobScheduled = true;
    
    var length = screenPointList.Length;
    _results = new NativeArray<Vector2>(screenPointList, Allocator.Persistent);
    _indexArray = new NativeArray<int>(length, Allocator.Persistent);
    _angleList = new NativeArray<float>(length, Allocator.Persistent);
    
    var calculateAngleJob = new CalculateAngleJob(_results, IndicatorManager.EllipticalCenter)
    {
        IndexArray = _indexArray,
        AngleArray = _angleList
    };
    var mapScreenToEllipseJob = new SpreadAngleByDistanceJob(
        _angleList,
        _indexArray,
        minGap,
        IndicatorManager.EllipticalCenter,
        IndicatorManager.EllipticalA,
        IndicatorManager.EllipticalB,
        iterations,
        rate)
    {
        PointsOnEllipse = _results
    };

    JobHandle advanceJob = default;
    if(advance)
    {
        var advanceMapJob = new AdvanceMapPointsJob(_results, IndicatorManager.EllipticalCenter,
            IndicatorManager.EllipticalA, IndicatorManager.EllipticalB);
        advanceJob = advanceMapJob.Schedule();
    }
    var jobHandle = calculateAngleJob.Schedule(advanceJob);
    _jobHandle = mapScreenToEllipseJob.Schedule(jobHandle);
}

public bool TryGetResults(out Vector2[] results)
{
    if (_isJobScheduled && _jobHandle.IsCompleted)
    {
        _isJobScheduled = false;
        _jobHandle.Complete();
        results = _results.ToArray();
        _results.Dispose();
        _indexArray.Dispose();
        _angleList.Dispose();
        return true;
    }
    results = default;
    return false;
}

在构造NativeArray时,我们使用了Allocator.Persistent,这是最慢的分配方式,但能够防止未及时调用TryGetResults而造成的内存警告。

应用Job进行计算

IndicatorManager中实例化一个MapScreenToEllipseJob

private readonly MapScreenToEllipse _mapper = new ();

添加一个方法StartJob,用于传入数据、启动Job:

/// <summary>
/// 开始Job,计算UI位置.<br/>
/// 若当前有Job正在执行,则忽略请求
/// </summary>
private void StartJob()
{
    var list = new Vector2[Sockets.Count];
    for (var index = 0; index < Sockets.Count; index++)
    {
        var socket = Sockets[index];
        var worldToScreenPoint = _mainCamera.WorldToScreenPoint(socket.slotTransform.position);
        list[index] = worldToScreenPoint;
        worldToScreenPoint.y = Screen.height - worldToScreenPoint.y;
        socket.OriginalPos = worldToScreenPoint;
        socket.IndicatorElement.TargetPoint = worldToScreenPoint;
    }
    _mapper.ScheduleJob(list,true);
}

添加一个方法TryApplyJobResult用于获取Job计算结果,并更新UI:

/// <summary>
/// 尝试获取Job的结果,并应用结果数据.<br/>
/// 若Job未完成,则什么也不会发生.
/// </summary>
private void TryApplyJobResult()
{
    if (!_mapper.TryGetResults(out var result)) return;
    if(result.Length != Sockets.Count)return;
    for (var index = 0; index < result.Length; index++)
    {
        Sockets[index].IndicatorElement.Position = result[index];
    }
}

ℹ️:我们的Job会保证输入与输出相同索引所对应的元素一定相同。

UpdateLateUpdate分别中调用这两种方法:

private void Update()
{
    StartJob();
    // 原始更新方法:
    // TransformWorldToScreenCoordinates();
    // TransformSocketToEllipse(true);
}
private void LateUpdate()
{
    TryApplyJobResult();
    // 绘制Debug椭圆
#if DEBUG_DRAW
    DrawEllipse();
#endif
}

效率对比

极端情况,屏幕上有100个指示线:
在这里插入图片描述

未启用Job - 70FPS 启用Job - 110FPS
在这里插入图片描述 在这里插入图片描述

更新椭圆匹配物体形状

所谓匹配形状,即是根据RendererBoundingBox,获取屏幕最小矩形,从而设置椭圆的长短轴大小。

获取Renderer最小屏幕矩形

实现一个函数GetScreenBoundingBox用于获取最小屏幕矩形:

private static Rect GetScreenBoundingBox(Renderer targetRenderer)
{
    // 获取包围盒
    var bounds = targetRenderer.localBounds;

    // 包围盒的6个面中心点
    var centerPoints = new Vector3[6];
    centerPoints[0] = new Vector3((bounds.min.x + bounds.max.x) / 2, bounds.min.y, (bounds.min.z + bounds.max.z) / 2); // 底面
    centerPoints[1] = new Vector3((bounds.min.x + bounds.max.x) / 2, bounds.max.y, (bounds.min.z + bounds.max.z) / 2); // 顶面
    centerPoints[2] = new Vector3(bounds.min.x, (bounds.min.y + bounds.max.y) / 2, (bounds.min.z + bounds.max.z) / 2); // 左面
    centerPoints[3] = new Vector3(bounds.max.x, (bounds.min.y + bounds.max.y) / 2, (bounds.min.z + bounds.max.z) / 2); // 右面
    centerPoints[4] = new Vector3((bounds.min.x + bounds.max.x) / 2, (bounds.min.y + bounds.max.y) / 2, bounds.min.z); // 前面
    centerPoints[5] = new Vector3((bounds.min.x + bounds.max.x) / 2, (bounds.min.y + bounds.max.y) / 2, bounds.max.z); // 后面

    // 旋转这些中心点
    targetRenderer.transform.TransformPoints(centerPoints);

    // 将旋转后的中心点转换到屏幕空间
    var screenPoints = new Vector2[6];
    for (var i = 0; i < centerPoints.Length; i++)
    {
        screenPoints[i] = _mainCamera.WorldToScreenPoint(centerPoints[i]);
    }
    // 计算最小矩形
    var minX = Mathf.Min(screenPoints[0].x, screenPoints[1].x, screenPoints[2].x, screenPoints[3].x, screenPoints[4].x, screenPoints[5].x);
    var maxX = Mathf.Max(screenPoints[0].x, screenPoints[1].x, screenPoints[2].x, screenPoints[3].x, screenPoints[4].x, screenPoints[5].x);
    var minY = Mathf.Min(screenPoints[0].y, screenPoints[1].y, screenPoints[2].y, screenPoints[3].y, screenPoints[4].y, screenPoints[5].y);
    var maxY = Mathf.Max(screenPoints[0].y, screenPoints[1].y, screenPoints[2].y, screenPoints[3].y, screenPoints[4].y, screenPoints[5].y);

#if DEBUG_DRAW
    //画出采样点
    Debug.DrawLine(centerPoints[0], centerPoints[1], Color.white); // 底面 -> 顶面
    Debug.DrawLine(centerPoints[2], centerPoints[3], Color.white); // 左面 -> 右面
    Debug.DrawLine(centerPoints[4], centerPoints[5], Color.white); // 前面 -> 后面
    // 绘制上下、左右前后
    Debug.DrawLine(centerPoints[0], centerPoints[2], Color.white); // 底面 -> 左面
    Debug.DrawLine(centerPoints[0], centerPoints[3], Color.white); // 底面 -> 右面
    Debug.DrawLine(centerPoints[1], centerPoints[2], Color.white); // 顶面 -> 左面
    Debug.DrawLine(centerPoints[1], centerPoints[3], Color.white); // 顶面 -> 右面
    Debug.DrawLine(centerPoints[4], centerPoints[2], Color.white); // 前面 -> 左面
    Debug.DrawLine(centerPoints[4], centerPoints[3], Color.white); // 前面 -> 右面
    Debug.DrawLine(centerPoints[5], centerPoints[2], Color.white); // 后面 -> 左面
    Debug.DrawLine(centerPoints[5], centerPoints[3], Color.white); // 后面 -> 右面
#endif
    
    // 创建并返回 Rect
    return Rect.MinMaxRect(minX, minY, maxX, maxY);
}

注意:函数获取每个面的中心,而不是直接取角点,可以有效消减无效的空间(尤其是当摄像机过于凑近Bounding时)。

取得Rect[]最小矩形

首先明确:物体可能由多个形状组成,因此应当获取父子的所有BoundingBox的最小矩形。建立函数GetBoundingRect用于获取包括所有Rect的最小Rect

private static Rect GetBoundingRect(Rect[] rects)
{
    if (rects.Length == 0) return Rect.zero; // 如果没有 Rect,返回零矩形
    // 初始化最小和最大值
    var minX = rects[0].xMin;
    var minY = rects[0].yMin;
    var maxX = rects[0].xMax;
    var maxY = rects[0].yMax;
    // 遍历所有的 Rect,更新最小值和最大值
    foreach (var rect in rects){
        minX = Mathf.Min(minX, rect.xMin);
        minY = Mathf.Min(minY, rect.yMin);
        maxX = Mathf.Max(maxX, rect.xMax);
        maxY = Mathf.Max(maxY, rect.yMax);
    }
    // 使用最小和最大值来创建包围矩形
    // maxY = Screen.height - maxY;
    // minY = Screen.height - minY;
    return new Rect(minX, minY, maxX - minX, maxY - minY);
}

同步椭圆到矩形

建立一个全局变量,用于保存当前的Renderer

private Renderer[] _renderer;

在Start函数中初始化_renderer

private void Start(){
	...其他代码...
	_renderer = displayObject.GetComponentsInChildren<Renderer>();
}

其中displayObject是当前正在展示的物体,请自行建立相关变量,并在Unity Editor中分配。

建立一个函数UpdateElliptical用于同步矩形:

private void UpdateElliptical()
{
	var enumerable = _renderer.Select(GetScreenBoundingBox).ToArray();
	_ellipticalBounds = GetBoundingRect(enumerable);
	
	var worldBound = _safeAreaElement.worldBound;
	worldBound.y = Screen.height -worldBound.yMax;
	safeArea = worldBound;
	AdjustRectB(ref _ellipticalBounds);
	var center = _ellipticalBounds.center;
	center.y = Screen.height - _ellipticalBounds.center.y;
	ellipticalCenter = center;
	ellipticalA = _ellipticalBounds.width / 2 + 100;
	ellipticalB = _ellipticalBounds.height / 2 + 100;
}

其中:
safeArea 是一个Rect,用于标识安全范围。
_safeAreaElement是一个VisualElement,用于标记安全区的范围大小,防止矩形超出屏幕距离。
限于篇幅原因,此处不给出声明方式,请读者自行申请全局变量和UI Element。
AdjustRectB用于调整矩形在安全矩形safeArea 范围内:

private static void AdjustRectB(ref Rect targetRect)
{
    // 确保 rectB 的左边界不小于 rectA 的左边界
    if (targetRect.xMin < SafeArea.xMin){
        targetRect.width -= (SafeArea.xMin - targetRect.xMin);
        targetRect.x = SafeArea.xMin;
    }

    // 确保 rectB 的右边界不大于 rectA 的右边界
    if (targetRect.xMax > SafeArea.xMax)
        targetRect.width -= (targetRect.xMax - SafeArea.xMax);

    // 确保 rectB 的上边界不大于 rectA 的上边界
    if (targetRect.yMax > SafeArea.yMax)
        targetRect.height -= (targetRect.yMax - SafeArea.yMax);

    // 确保 rectB 的下边界不小于 rectA 的下边界
    if (targetRect.yMin < SafeArea.yMin){
        targetRect.height -= (SafeArea.yMin - targetRect.yMin);
        targetRect.y = SafeArea.yMin;
    }
}

在更新ellipticalAellipticalB 时,我直接使用硬编码的方式,让其始终长100单位距离。这显然是欠妥的,不过安全区限制了其副作用,因此可以使用这种方式。

在这里插入图片描述

高级属性面板

ℹ️:本节给出一个元素的继承例子,最大化的复用相同代码。

⚠️:属性面板与我实现的武器属性遗传算法高度关联,而后者的实现复杂度远高于整篇文章,篇幅原因,我只给出UI的实现逻辑。

❌:本节代码仅供参考,无法在没有遗传算法的情况下发挥功能

属性面板的特别之处在于其是由多个子属性信息元素组合而成,因此我们需要先实现子属性信息元素,其中子属性信息元素分为两类:

  • AttributeElement:显示组件的固有属性,用于选择栏目中的属性预览。表现为文字+数字:在这里插入图片描述

  • InheritedAttributeElement:显示组件的固有+被子组件影响的属性,用于武器的属性总览。表现为多个进度条:

这两类子属性信息元素都继承于同一个基类AttributeValueElementBase

  • 提供了基础信息展示:属性名称、属性说明文本、属性数值。
  • 提供了基础事件:当鼠标移入时的事件(默认为展开属性说明文本)。
  • 提供了虚函数,允许重写自定义属性名称、数值的更新显示逻辑。

考虑到InheritedAttributeElementAttributeElement关键功能都是基于AttributeValueElementBase虚函数实现的,在这里我必须给出AttributeValueElementBase完整逻辑:

由于AttributeValueElementBase为抽象类,因此没有UxmlFactoryUxmlTraits

public abstract class AttributeValueElementBase : VisualElement {
	private AttributeValue _attributeDataBase;
    protected AttributeValue AttributeDataBase {
        get => _attributeDataBase;
        set {
            _attributeDataBase = value;
            UpdateValue();
        }
    }
    public AttributeHeader AttributeHeader { get; set; }

    private readonly DynamicLabelElement _label;
    private readonly DynamicLabelElement _value;
    private readonly DynamicLabelElement _descriptionLabel;
    protected readonly VisualElement ValueArea;
    protected readonly VisualElement BodyArea;
    
    protected abstract void OnMouseEnter();
    protected abstract void OnMouseLeave();

    protected void Init(AttributeHeader attributeHeader, AttributeValue attributeData) {
        AttributeHeader = attributeHeader;
        _attributeDataBase = attributeData;
    }

    /// <summary>
    /// 渲染标题和值
    /// </summary>
    /// <param name="title">标题</param>
    /// <param name="value">值</param>
    /// <returns>是否进行接下来的值更新OnUpdateValue</returns>
    protected virtual bool OnRenderLabel(DynamicLabelElement title,DynamicLabelElement value) {
        title.TargetText = AttributeHeader.AttributeName;
        value.TargetText = $"{_attributeDataBase.Value:F1}";
        return true;
    }
    /// <summary>
    /// 当值更新时的逻辑
    /// </summary>
    protected abstract void OnUpdateValue();

    public void UpdateValue() {
        if (OnRenderLabel(_label,_value))
            OnUpdateValue();
    }
    
    private void RegisterEvents() {
        RegisterCallback<MouseEnterEvent>(_ => {
            style.backgroundColor = new Color(0, 0, 0, 0.2f);
            _descriptionLabel.style.display = DisplayStyle.Flex;
            _descriptionLabel.TargetText = AttributeHeader.AttributeDescription;
            OnMouseEnter();
        });
        RegisterCallback<MouseLeaveEvent>(_ => {
            style.backgroundColor = new Color(0, 0, 0, 0.0f);
            _descriptionLabel.TargetText = "";
            _descriptionLabel.style.display = DisplayStyle.None;
            OnMouseLeave();
        });
    }

    protected AttributeValueElementBase() {
        style.paddingTop = 5;
        style.paddingBottom = 5;
        var title = new VisualElement() {
            style = {
                flexDirection = FlexDirection.Row, justifyContent = Justify.SpaceBetween
            }
        };
        Add(title);
        _label = new DynamicLabelElement {
            style = {
                color = Color.white, marginLeft = 10, marginRight = 10, marginTop = 5, marginBottom = 5,
            },
            RevealSpeed = 0.05f,
            RandomTimes = 3,
        };
        title.Add(_label);
        ValueArea = new VisualElement {
            style = {
                flexDirection = FlexDirection.Row,
            }
        };
        title.Add(ValueArea);
        _value = new DynamicLabelElement {
            style = {
                unityFontStyleAndWeight = FontStyle.Bold, color = Color.black, marginLeft = 10, marginRight = 10, marginTop = 5, marginBottom = 5, backgroundColor = Color.white
            },
            RevealSpeed = 0.1f,
            RandomTimes = 5,
        };
        ValueArea.Add(_value);
        BodyArea = new VisualElement {
            style= {
                marginTop = 5
            }
        };
        Add(BodyArea);
        _descriptionLabel = new DynamicLabelElement {
            style = {
                unityFontStyleAndWeight = FontStyle.Normal, color = Color.gray, marginLeft = 10, marginRight = 10, marginTop = 5, marginBottom = 5, 
                display = DisplayStyle.None
            },
            RevealSpeed = 0.05f,
            RandomTimes = 3
        };
        Add(_descriptionLabel);
        
        RegisterEvents();
        style.transitionProperty = new List<StylePropertyName> { "all" };
        style.transitionDelay = new List<TimeValue> { 0f};
        style.transitionDuration =  new List<TimeValue> { 0.3f};
        style.transitionTimingFunction = new List<EasingFunction> { EasingMode.Ease };
    }
}

其中:
AttributeHeader是属性头,包含了属性标识符(用于支持跨语种翻译)、属性描述、属性名、属性数值类型。
AttributeValue是属性值,其包含一个最关键的float型变量value,表示属性的值以及其他的辅助成员用于标识该属性的计算法、影响范围等。

AttributeElement

例如:AttributeElement中,需要在数值后面显示计算法(绿色的加法):
在这里插入图片描述
实现方式为重写OnUpdateValue方法:

protected override void OnUpdateValue()
{
    switch (AttributeData.CalcMethod)
    {
        case CalculationMethod.Add:
            _calc.TargetText = "+";
            _calc.style.color = Color.green;
            break;
        case CalculationMethod.Subtract:
            _calc.TargetText = "-";
            _calc.style.color = Color.red;
            break;
        case CalculationMethod.Multiply:
            _calc.TargetText = "*倍乘";
            _calc.style.color = Color.white;
            break;
        case CalculationMethod.Override:
            _calc.TargetText = "·覆盖";
            _calc.style.color = Color.yellow;
            break;
        default:
            break;
    }
}

其中_calc是该子类新添加的DynamicLabelElement元素:

public AttributeElement(){
    _calc = new DynamicLabelElement{
        style ={
            color = Color.white,
            marginRight = 10,
            marginTop = 5,
            marginBottom = 5,
        },
        RevealSpeed = 0.1f,
        RandomTimes = 5,
    };
    ValueArea.Add(_calc);
}

有些时候属性是布尔值而非具体数值,例如是否防水:
在这里插入图片描述
此时不应显示任何数值信息,此时我们只需重写OnRenderLabel并让其返回false即可阻止OnUpdateValue发生:

protected override bool OnRenderLabel(DynamicLabelElement title, DynamicLabelElement value)
{
    style.display = DisplayStyle.Flex;
    switch (AttributeHeader.Type)
    {
        default:
        case AttributeType.Float:
        case AttributeType.Range100:
        case AttributeType.Range01:
            title.style.color = Color.white;
            value.style.display = DisplayStyle.Flex;
            base.OnRenderLabel(title, value);
            break;
        case AttributeType.Bool:
            switch (AttributeDataBase.Value)
            {
                case < -0.5f:
                    title.style.color = Color.red;
                    _calc.TargetText = "减益";
                    _calc.style.color = Color.red;
                    break;
                case > 0.5f:
                    title.style.color = Color.green;
                    _calc.TargetText = "增益";
                    _calc.style.color = Color.green;
                    break;
                default:
                    //Bool为0 则直接隐藏本条属性
                    style.display = DisplayStyle.None;
                    break;
            }
            title.TargetText = AttributeHeader.AttributeName;
            value.style.display = DisplayStyle.None;
            return false;
    }
    return true;
}

从逻辑中也能一窥布尔类型的表示方法:通过判断浮点的绝对数值大小是否大于0.5。
至于正负是为了表示该属性对玩家的意义积极与否。

InheritedAttributeElement

这个元素最有趣的点之一莫过于进度条:
在这里插入图片描述
为了实现更高效的管理进度条,每个进度条实际上都是一层封装:

private class ProgressBar{
    private readonly VisualElement _labelBackground;
    private readonly VisualElement _progress;
    private readonly Label _label;
    private bool _displayLabel;
    private float _percent;
    private Color _color;
    public WeaponComponentBase TargetComponent;
    private static IVisualElementScheduledItem _indicatorScheduled;
    public ProgressBar(Color color = default) {
        Root = new VisualElement();
        _progress = new VisualElement() {
            style = { flexGrow = 1 }
        };
        _labelBackground = new VisualElement() {
            name = "label-background",
            style = {
                display = DisplayStyle.None, position = Position.Absolute, right = 0,
                bottom = new Length(100, LengthUnit.Percent)
            }
        };
        _label = new Label("") {
            style = {
                marginBottom = 0, marginLeft = 0, marginRight = 0, marginTop = 0,
                paddingLeft = 0, paddingRight = 0, paddingTop = 0, paddingBottom = 0
            }
        };
        _labelBackground.Add(_label);
        Root.Add(_labelBackground);
        Root.Add(_progress);
        Color = color;
        Root.style.transitionProperty = new List<StylePropertyName> { "all" };
        Root.style.transitionDuration =  new List<TimeValue> { 0.3f};
        var oldAlpha = _color.a;
        Root.RegisterCallback<MouseOverEvent>(_ => {
            if (AttributeIndicatorManager.IndicatorVisible) {
                IndicatorTarget();
            }
            else {
                _indicatorScheduled?.Pause();
                _indicatorScheduled = Root.schedule.Execute(IndicatorTarget).StartingIn(500);
            }
            oldAlpha = _color.a;
            Color = Color.WithAlpha(1f);
        });
        Root.RegisterCallback<MouseOutEvent>(_ => {
            Color = Color.WithAlpha(oldAlpha);
            if (_indicatorScheduled != null) {
                _indicatorScheduled.Pause();
                _indicatorScheduled = null;
            }
            else
                AttributeIndicatorManager.ShrinkIndicator();
        });
    }
    private void IndicatorTarget() {
        _indicatorScheduled?.Pause();
        _indicatorScheduled = null;
        AttributeIndicatorManager.SetIndicatorTarget(
            _progress.worldBound.min,
            _progress.worldBound.max,
            TargetComponent,
            Color
        );
    }
    private float FitMaxFontSize(string text, float maxHeight, float begin = 1f, float end = 50) {
        var minFontSize = begin;  // 最小字体大小
        var maxFontSize = end; // 假定的最大字体大小
        while (maxFontSize - minFontSize > 0.5f)  {
            var fontSize = (minFontSize + maxFontSize) / 2f;
            FontSize = fontSize;
            var textSize = _label.MeasureTextSize(text, 0, 0, 0, 0);
            if (textSize.y <= maxHeight) 
                minFontSize = fontSize;
            else 
                maxFontSize = fontSize;
        }
        return (minFontSize + maxFontSize) / 2f;
    }
    public bool DisplayLabel {
        get => _displayLabel;
        set {
            _displayLabel = value;
            _labelBackground.style.display = _displayLabel ? DisplayStyle.Flex : DisplayStyle.None;
        }
    }
    public bool ShowEdge {
        set => Root.style.paddingLeft = value ? 1 : 0;
    }
    public float Percent {
        get => _percent;
        set {
            _percent = value;
            LabelText = $"{value:F0}%";
            Root.style.width = new Length(_percent, LengthUnit.Percent);
        }
    }
    public Color Color {
        get => _color;
        set {
            _color = value;
            _progress.style.backgroundColor = _color;
            Root.style.color = _color;
        }
    }
    public string LabelText {
        get => _label.text;
        set => _label.text = value;
    }
    public float FontSize {
        set => _label.style.fontSize = value;
    }
    public float UpdateFontSize(float height) {
        if (height < 0)
            height = Root.layout.height;
        return FitMaxFontSize(LabelText, height);
    }
    public VisualElement Root { get; }
}

在这个进度条中,处理了鼠标交互事件,封装了百分比属性,更快速的设置进度条长度。

但有几点值得注意:

  • WeaponComponentBase:是一个基类,你可以将其理解为GameObject
  • FitMaxFontSize:计算某个空间中,所能容纳的最大的字体大小
  • IndicatorTarget启动属性来源指示器
  • _indicatorScheduled :用于在启动属性来源指示器前进行计时,从而实现悬停0.5秒触发。

之后与AttributeElement相同,重写OnRenderLabelOnUpdateValue。并在其中处理进度条相关的逻辑,由于涉及到了属性值遗传计算,外加篇幅原因,在这里就不展开了。

属性来源指示器

ℹ️:通过UI+程序结合的方式实现功能
使用到了generateVisualContent 生成网格图形

属性来源指示器实现了一种类似于“3D场景中的物体浮现于UI元素之上”的视觉效果。
在这里插入图片描述
其原理是生成组件的渲染图,并将其设置为Visual Element的背景图,让Visual Element的位置与渲染图位置重合:
在这里插入图片描述
(图中绿色方框为Visual Element
至于黄色部分,则使用了generateVisualContent来绘制,不同的是它是一个三维的网格模型,而不是2D笔刷所绘制。
首先实现黄色部分的元素,称其为属性指示器 AttributeIndicator

属性指示器

属性指示器总是由程序控制属性,因此不需要UxmlTraits

如图所示,起需要四个Vector2属性用于标定四个顶点:
在这里插入图片描述
基础框架为:

public class AttributeIndicator : VisualElement 
{
    public new class UxmlFactory : UxmlFactory<AttributeIndicator,UxmlTraits> {}
    public Color Color { get; set;}//颜色
    public bool IndicatorVisible; //是否可见
    public Vector2 BeginPointA; 
    public Vector2 BeginPointB;
    public Vector2 EndPointA;
    public Vector2 EndPointB;
    private VisualElement _overlay;//组件的覆盖图片
    private DynamicLabelElement _overlayLabel;//组件的描述文字
    private readonly RectangleMesh _rectangleMesh;//四边形网格 为节省篇幅直接在这里给出,类型定义见下文
    public AttributeIndicator()
    {
    	pickingMode = PickingMode.Ignore;
        style.position = Position.Absolute;
        style.top = 0;
        style.left = 0;
        style.right = 0;
        style.bottom = 0;
        //四边形网格 为节省篇幅直接在这里给出,类型定义见下文
        _rectangleMesh = new RectangleMesh(BeginPointA, BeginPointB,EndPointA, EndPointB,Color.cyan);  // 初始化矩形网格
        _overlay = new VisualElement() {
            style = {
                position = Position.Absolute,
                alignItems = Align.Center,
                justifyContent = Justify.Center,
            },
            pickingMode = PickingMode.Ignore,
        };
        _overlayLabel = new DynamicLabelElement {
            style = {
                fontSize = 16,
                color = Color.black,
                backgroundColor = Color.white,
            },
            RevealSpeed = 0.1f,
            RandomTimes = 5,
            enableRichText = false
        };
        _overlay.Add(_overlayLabel);
        Add(_overlay);
        generateVisualContent += DrawMeshes;// 绘制网格
    }
    public void StickOverlay(Rect areaRect, RenderTexture texture,string title = ""){
        areaRect = this.WorldToLocal(areaRect);
        _overlayLabel.TargetText = title;
        _overlay.style.backgroundImage = Background.FromRenderTexture(texture);
        _overlay.style.width = areaRect.width;
        _overlay.style.height = areaRect.height;
        _overlay.style.top = areaRect.y;
        _overlay.style.left = areaRect.x;
        _overlay.style.display = DisplayStyle.Flex;
    }
    public void HideOverlay(){
        _overlay.style.backgroundImage = null;
        _overlayLabel.TargetText = "";
        _overlay.style.display = DisplayStyle.None;
        IndicatorVisible = false;
    }
}

其中:
StickOverlay用于显示组件的图片。
HideOverlay用于隐藏组件图片。

该元素的大小为完全覆盖整个屏幕,由于设置了pickingMode = PickingMode.Ignore因此不会阻挡鼠标、键盘的的事件。

其中generateVisualContent 委托绑定的是DrawMeshes函数,用于绘制网格形状。

绘制网格形状

// 绘制网格
private void DrawMeshes(MeshGenerationContext context)
{
    // 获取矩形的网格数据
    _rectangleMesh.UpdateMesh();
    // 分配网格内存
    var meshWriteData = context.Allocate(RectangleMesh.NumVertices, RectangleMesh.NumIndices);
    // 设置网格顶点
    meshWriteData.SetAllVertices(_rectangleMesh.Vertices);
    // 设置网格索引
    meshWriteData.SetAllIndices(_rectangleMesh.Indices);
}

其中RectangleMesh是一个自定义的类型,用于管理四边形网格数据:

public class RectangleMesh
{
    public const int NumVertices = 4; // 矩形有4个顶点
    public const int NumIndices = 6; // 2个三角形,每个三角形3个顶点,6个索引
    private Vector2 _beginPointA;
    private Vector2 _beginPointB;
    private Vector2 _endPointA;
    private Vector2 _endPointB;
    public Color Color;
    public readonly Vertex[] Vertices = new Vertex[NumVertices];  // 使用 Vertex 结构体数组来存储顶点
    public readonly ushort[] Indices = new ushort[NumIndices];   // 存储三角形的索引
    private bool _isDirty = true;
    public RectangleMesh(Vector2 beginPointA, Vector2 beginPointB, Vector2 endPointA, Vector2 endPointB, Color color){
        _beginPointA = beginPointA;
        _beginPointB = beginPointB;
        _endPointA = endPointA;
        _endPointB = endPointB;
        Color = color;
    }
    private static Vector3 GetV3(Vector2 v2){
        return new Vector3(v2.x, v2.y, Vertex.nearZ);
    }
    public void UpdateData(Vector2 beginPointA, Vector2 beginPointB, Vector2 endPointA, Vector2 endPointB){
        _beginPointA = beginPointA;
        _beginPointB = beginPointB;
        _endPointA = endPointA;
        _endPointB = endPointB;
        _isDirty = true;
    }
    // 更新矩形网格的顶点和索引
    public void UpdateMesh(){
        if (!_isDirty)
            return;
        // 计算矩形的4个顶点,并使用 Vertex 结构体存储位置和颜色
        Vertices[0].position = GetV3(_beginPointA);
        Vertices[0].tint = Color;
        Vertices[1].position = GetV3(_beginPointB);
        Vertices[1].tint = Color;
        var endColor = new Color(Color.r, Color.g, Color.b, 0f);
        Vertices[2].position = GetV3(_endPointA);
        Vertices[2].tint = endColor;
        Vertices[3].position = GetV3(_endPointB);
        Vertices[3].tint = endColor;
        
        // 计算矩形的索引,这里我们用2个三角形来填充矩形
        Indices[0] = 0; // 左下角
        Indices[1] = 1; // 右下角
        Indices[2] = 2; // 左上角

        Indices[3] = 1; // 右下角
        Indices[4] = 3; // 右上角
        Indices[5] = 2; // 左上角

        // 计算第一个三角形(0, 1, 2)和第二个三角形(1, 3, 2)的法线
        var normal1 = CalculateNormal2D(_beginPointA, _beginPointB, _endPointA);
        var normal2 = CalculateNormal2D(_beginPointA, _endPointB, _endPointA);
        // 判断法线方向与视线方向的点积,决定是否需要调整顺序
        if (normal1 < 0){  // 如果第一个三角形的法线方向与视点方向不一致,则交换顶点顺序
            Indices[0] = 0;
            Indices[1] = 2; // 左下角
            Indices[2] = 1; // 右下角
        }
        if (normal2 < 0){  // 如果第二个三角形的法线方向与视点方向不一致,则交换顶点顺序
            Indices[3] = 1;
            Indices[4] = 2; // 右上角
            Indices[5] = 3; // 左上角
        }

        _isDirty = false;
    }
    // 计算二维法线
    private static float CalculateNormal2D(Vector2 v0, Vector2 v1, Vector2 v2){
        var edge1 = v1 - v0;
        var edge2 = v2 - v0;
        // 计算二维叉积
        return edge1.x * edge2.y - edge1.y * edge2.x;
    }
}

注意三角形绕旋方向十分重要,反向的法向将被剔除,因此需要手动进行纠正。

更新网格形状

为了能够快速修改形状,添加两个函数UpdateDataShrinkUpdate用于支持动画化的修改网格形状:

//延展矩形网格
public void UpdateData(Vector2 minWorld, Vector2 maxWorld, Vector2 endPointA, Vector2 endPointB, Color color)
{
    var minLocal = this.WorldToLocal(minWorld);
    var maxLocal = this.WorldToLocal(maxWorld);
    BeginPointA = minLocal;
    BeginPointB = maxLocal;
    EndPointA = endPointA;
    EndPointB = endPointB;
    Color = color;
    DoUpdate();
}
//收缩矩形网格
public void ShrinkUpdate()
{
    DoUpdate(true);
}

//执行真正的网格更新操作
private ValueAnimation<float> _animation;
private float _lastValue;
private void DoUpdate(bool backward = false)
{
    _rectangleMesh.Color = Color;
    var from = _lastValue;
    var to = backward ? 0f : 1f;

    if (!backward) IndicatorVisible = true;
    if (_animation is { isRunning: true })
    {
        _animation.Stop();
    }
    _animation = experimental.animation.Start(from, to, 1000, (_, f) =>
    {
        var ep1 = Vector2.Lerp(BeginPointA, EndPointA, f);
        var ep2 = Vector2.Lerp(BeginPointB, EndPointB, f);
        _lastValue = f;
        // _fontColor.a = f;
        _rectangleMesh.UpdateData(BeginPointA, BeginPointB, ep1, ep2);
        MarkDirtyRepaint();
        if(backward) HideOverlay();
    });
}

之后将此元素添加到UI 文档中,并命名便于查找。

AttributeIndicatorManager

仅使用AttributeIndicator无法做到预期效果,我们需要能够截取屏幕上的物体单独渲染图,并实时更新图片的效果。
为了便于管理这个一个过程,建立一个MonoBehaviourAttributeIndicatorManager

生成屏幕上物体的独立渲染图

首先在场景中新建一个摄像机,要求与主摄像机属性与位置完全一致,且设置为主摄像机的子级。但其剔除层要选择一个没有其他物体的空白层,用于单独渲染物体
添加属性:

private static AttributeIndicatorManager _instance;//本身的全局单例
private static AttributeIndicator _indicator; //UI元素
public Camera renderCamera;
private Texture2D _texture;//裁剪后图像
private RenderTexture _rt;//可更新的RT
private bool _updateRT;//可以对rt进行更新
private bool _rtCreated;//rt已创建
public Shader renderShader;//后处理shader,为节省篇幅,这里提前给出定义

进行初始化(根据实际情况修改):

private void Awake()
{
    _instance = this;
    _mainCamera = Camera.main;
    _uiDocument = GetComponent<UIDocument>();
    _indicator = _uiDocument.rootVisualElement.Q<AttributeIndicator>("attributeIndicator");
    _renderMaterial = new Material(renderShader);
}

添加函数RenderComponent,用于单独渲染目标。
其原理是设置物体层为其他层(这里是UI层)触发渲染后立刻恢复物体到原始层:

/// <summary>
/// 单独渲染屏幕上的目标
/// </summary>
/// <param name="target">目标物体</param>
/// <param name="rect">屏幕上的区域(将屏幕裁剪为此区域)</param>
private void RenderComponent(GameObject target,Rect rect)
{
    _updateRT = false;//不允许对rt进行更新
    var oldLayer = target.layer;
    var renderTexture = RenderTexture.GetTemporary(Screen.width, Screen.height, 0, RenderTextureFormat.ARGB32);
    renderCamera.targetTexture = renderTexture;
    target.layer = LayerMask.NameToLayer("UI");
    renderCamera.Render();
    target.layer = oldLayer;
    renderCamera.targetTexture = null;
    //初始化_texture 持久保存当前的画面
    if(_texture == null)
        _texture = new Texture2D((int)rect.width, (int)rect.height, TextureFormat.ARGB32, false);
    else
        _texture.Reinitialize((int)rect.width, (int)rect.height, TextureFormat.ARGB32, false);
    Graphics.CopyTexture(renderTexture, 0,0,(int)rect.x,(int)rect.y,(int)rect.width,(int)rect.height,_texture,0,0,0,0);
    RenderTexture.ReleaseTemporary(renderTexture);
    if (_rtCreated || _rt is not null){
        if (_rt.IsCreated())
            _rt.Release();
    }
    _rt = new RenderTexture(_texture.width, _texture.height,0, RenderTextureFormat.ARGB32);
    _rt.Create();
	
	//允许rt进行更新
    _updateRT = true;
    _rtCreated = true;//rt已创建
}

RenderComponent中,将图像保存到_texture

其中最关键的在于_texture, 它用于保存渲染结果。
至于其中的_updateRT_rtCreated_rt 则为下文做铺垫,为节省篇幅我直接给出声明与更新,而不是在下文中再重复一次这个函数。

对图片实时施加Shader效果

我们希望图片能够有滚动条纹效果,此时使用Shader是唯一简便的方法,因此我们需要在Update中使用Graphics.Blit

private void Update()
{
    if (_updateRT)
    {
        Graphics.Blit(_texture,_rt,_renderMaterial);
    }
}

其中_renderMaterial是你希望对其处理的shader材质,请自行定义并绑定shader,例如:

Shader "Custom/StripeEffect"{
    Properties{
        _MainTex ("Base Texture", 2D) = "white" {}
        _Color("Color", Color) = (1,0,0,1)
    }
    SubShader{
        Pass{
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"

            sampler2D _MainTex;
            float4 _MainTex_ST;
            float4 _Color;
            
            v2f_img vert(appdata_base v){
                v2f_img o;
                o.pos = UnityObjectToClipPos(v.vertex);
                o.uv = v.texcoord;
                return o;
            }

            half4 frag(v2f_img i) : SV_Target {
                half4 color = tex2D(_MainTex, i.uv);
                i.uv.y += i.uv.x + _Time.x;
                i.uv.y = frac(i.uv.y *= 10);
                color = lerp(color,_Color,step(.5,i.uv.y * color.a));
                return color;
            }
            ENDCG
        }
    }
    FallBack "Diffuse"
}

由此,一旦RenderComponent执行完成,Update能够立刻对_texture进行处理,实时的施加一个Shader效果。

自动化AttributeIndicator

添加一个静态SetIndicatorTarget方法用于允许任何地方调用,同时简化调用,只需提供起点两个顶点坐标而无需终点坐标:

public static void SetIndicatorTarget(Vector2 worldBeginPointA, Vector2 worldBeginPointB, GameObject target, Color color)
{
    var screenBoundingBox = GetScreenBoundingBox(target.GetComponent<Renderer>());
    _instance._renderMaterial.SetColor(SColor,color);
    _instance.RenderComponent(target.gameObject,screenBoundingBox);
    var min = screenBoundingBox.min;
    var max = screenBoundingBox.max;
    min.y = Screen.height - min.y;
    max.y = Screen.height - max.y;
    
    screenBoundingBox.y = Screen.height - screenBoundingBox.y;
    screenBoundingBox.y -= screenBoundingBox.height;
    
    CalculatePointsBC(screenBoundingBox, (worldBeginPointA + worldBeginPointB) / 2,out var b,out var c );
    b = _indicator.WorldToLocal(b);
    c = _indicator.WorldToLocal(c);

    _indicator.StickOverlay(screenBoundingBox, _instance._rt,"要显示的标题");
    _indicator.UpdateData(worldBeginPointA, worldBeginPointB, b, c, color);
}

其中GetScreenBoundingBox用于获取最小屏幕矩形,不过与之前不同的是,这里需要取拐点而不是取每个面的中点(为了防止打乱重要性排布,代码在下文CalculatePointsBC之后给出)
其中CalculatePointsBC用于计算最佳的对角线。例如为了避免一下情况:
在这里插入图片描述
在这里插入图片描述
CalculatePointsBC的解题方式是计算三角形面积,选择面积最大的一种情况:

private static void CalculatePointsBC(Rect rect, Vector2 pointA,out Vector2 bestB,out Vector2 bestC){ 
    Vector2[] rectCorners = {
        new(rect.xMin, rect.yMin), // (top-left)
        new(rect.xMax, rect.yMax), // (bottom-right)
        
        new(rect.xMax, rect.yMin), // (top-right)
        new(rect.xMin, rect.yMax), // (bottom-left)
    };
    bestB = Vector2.zero;
    bestC = Vector2.zero;
    if (TriangleArea2(pointA,rectCorners[0],rectCorners[1]) > TriangleArea2(pointA,rectCorners[2],rectCorners[3])){
        bestB = rectCorners[0];
        bestC = rectCorners[1];
    }
    else{
        bestB = rectCorners[2];
        bestC = rectCorners[3];
    }
}

// 计算三角形ABC的面积
private static float TriangleArea2(Vector2 a, Vector2 b, Vector2 c){
    return Mathf.Abs((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x));
}

GetScreenBoundingBox用于获取最小屏幕矩形(与自动更新椭圆章节相比,这里是取拐角而非取面中心):

private static Rect GetScreenBoundingBoxOld(Renderer targetRenderer)
{
    // 获取物体边界框的八个顶点
    var bounds = targetRenderer.bounds;
    var vertices = new Vector3[8];
    vertices[0] = bounds.min;
    vertices[1] = new Vector3(bounds.min.x, bounds.min.y, bounds.max.z);
    vertices[2] = new Vector3(bounds.min.x, bounds.max.y, bounds.min.z);
    vertices[3] = new Vector3(bounds.min.x, bounds.max.y, bounds.max.z);
    vertices[4] = new Vector3(bounds.max.x, bounds.min.y, bounds.min.z);
    vertices[5] = new Vector3(bounds.max.x, bounds.min.y, bounds.max.z);
    vertices[6] = new Vector3(bounds.max.x, bounds.max.y, bounds.min.z);
    vertices[7] = bounds.max;

    // 将每个顶点转换到屏幕坐标
    var minScreenPoint = new Vector2(float.MaxValue, float.MaxValue);
    var maxScreenPoint = new Vector2(0f, 0f);

    foreach (var t in vertices){
        var screenPoint = _mainCamera.WorldToScreenPoint(t);
        // 更新最小和最大屏幕坐标
        minScreenPoint.x = Mathf.Min(minScreenPoint.x, screenPoint.x);
        minScreenPoint.y = Mathf.Min(minScreenPoint.y, screenPoint.y);
        maxScreenPoint.x = Mathf.Max(maxScreenPoint.x, Mathf.Max(0, screenPoint.x));
        maxScreenPoint.y = Mathf.Max(maxScreenPoint.y, Mathf.Max(0, screenPoint.y));
    }
    // 创建并返回 Rect
    return Rect.MinMaxRect(minScreenPoint.x, minScreenPoint.y, maxScreenPoint.x, maxScreenPoint.y);
}

使用方式

正如前一节InheritedAttributeElementProgress的介绍,只需要:

AttributeIndicatorManager.SetIndicatorTarget(
    _progress.worldBound.min,
    _progress.worldBound.max,
    TargetComponent,
    Color
);

其中_progress.worldBound.min_progress.worldBound.max组成了进度条的对角线。

关于武器属性遗传算法

在这里插入图片描述
由于篇幅原因,这里就不展开,视情况更新相关的解析教程。


文章如有不当之处,还望指正

Logo

分享前沿Unity技术干货和开发经验,精彩的Unity活动和社区相关信息

更多推荐