在本文中,我将使用UI Toolkit
实现自定义的插槽、指示器
等部件UI。
文章的子章节大致以由简至难排布。
与以往相同,在开始之前,我先给出本文所实现的效果。
-
高性能指示器
-
可控的过渡动画(指示线的逻辑)
-
高级属性面板(完全由自定义组件组成)
5. 属性来源指示器
ℹ️信息
- 此片文章着重讲解UI的实现,武器属性遗传系统非文章重点。
- 在每一节标题我会给出关键的注意事项,这样即使你对此效果不感兴趣,也应该能够挑选有价值的部分细读。
文章目录
- 部件指示器
- 指示线组件 IndicatorElement
- 属性设计
- 绘制自定义2D形状
- 插槽组件 SocketElement
- 使用代码生成结构与设置style
- 属性设计
- experimental.animation实现可控动画插值
- 实现无限滚动的背景动画
- 完善UxmlTraits与自绘逻辑
- 鼠标响应事件
- 动态字符标签 DynamicLabelElement
- 属性设计
- 总体逻辑
- 部件指示器UI更新逻辑
- 指示线管理器 IndicatorManager
- 更新指示线让其总指向场景物体
- 更新插槽让其沿着椭圆排布
- DebugDraw绘制目标椭圆
- 排布`插槽`到椭圆
- 力导向分散算法
- 使用力导向算法
- 预排布
- 使用Unity Job加速运算
- Job管理器:MapScreenToEllipseJob
- 计算Angle并排列顺序Job
- 预排布Job
- 力导向Job
- 整合Job,分配任务、获取结果
- 应用Job进行计算
- 效率对比
- 更新椭圆匹配物体形状
- 获取Renderer最小屏幕矩形
- 取得Rect[]最小矩形
- 同步椭圆到矩形
- 高级属性面板
- AttributeElement
- InheritedAttributeElement
- 属性来源指示器
- 属性指示器
- 绘制网格形状
- 更新网格形状
- AttributeIndicatorManager
- 生成屏幕上物体的独立渲染图
- 对图片实时施加Shader效果
- 自动化AttributeIndicator
- 使用方式
- 关于武器属性遗传算法
部件指示器
ℹ️实现两个组件:
指示线
、插槽
。
指示线
组件,根据起点与末点,在屏幕上绘制出直线。插槽
组件,可进行点击交互,处理鼠标相关的事件。ℹ️实现一种Label平替元素:
DynamicLabelElement
:实现逐字拼写、乱码特效。
指示线组件 IndicatorElement
ℹ️此节将会对实现自定义组件的基础进行详细介绍。
ℹ️在先前的文章中,我有几篇UI Toolkit的自定义组件的基础教程。实现自定义UI时买必须确保:
- 至少确保其继承自
VisualElement
或其子类。- 需要实现类型为
UxmlFactory<你的组件名>
的Factory。- 如果你希望能够在UI Builder中添加可调整的自定义的属性,需要实现
UxmlTraits
,并在将Factory的类型改为UxmlFactory<你的组件名,你的UxmlTraits>
。
⚠️注意:
UxmlFactory
、UxmlTraits
用于与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};
}
⚠️注意:
UxmlAttributeDescription
的name
属性,必须采用连字符命名法(单词之间用连字符 - 分隔)。
UxmlAttributeDescription
的name
属性,必须与C#属性名相似(如test-p
与TestP
、testP
、testp
是相似的),否则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(){//构造函数}
}
⚠️注意:
所有被UxmlTraits
中UxmlAttributeDescription
的name
属性,不能与类内任何类型冲突的C#属性名相似,例如:
- 在
UxmlTraits
存在一个UxmlFloatAttributeDescription
的name
为test-p
的属性,则IndicatorElement
类内不允许任何类型与float
冲突、且名称与TestP
、testP
、testp
相同的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,在这里,element
是IndicatorElement
实例。
ℹ️
UI Toolkit
的世界坐标系以左上角为原点
。
此时可以直接在UI Builder中查看效果,通过调整属性检查是否正常工作。
⚠️ 注意:
由于使用了世界坐标,而UI Builder
本身以UI Toolkit
构建,因此绘制的内容会突破UI Builder
的范围。
插槽组件 SocketElement
ℹ️此节将着重对使用代码生成元素结构、控制元素动态表现的技巧进行介绍。
结构 | 效果 |
---|---|
插槽 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
):次标签
StripeBackgroundElement
是自定义的组件,用于显示无限上下滚动的条纹,将会在后文进行实现。Image
是内置的图片组件,用于显示图标。插槽图标
与安装的组件图标
只能显示其中一个,即stripe-background
与content-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){...绘制边框代码... }
}
ℹ️注意:
- 使用代码设置style的百分比值时,须使用
Length.Percent()
。- 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 | 线条宽度(如果有指示器则是指示器的数值) |
注意其中的LineColor
、LineWidth
、Opacity
、AnimOpacity
、AnimPosition
属性,如果父级为指示线
,则其将会与指示线
的数值双向同步
,否则将会使用默认值,因此我们需要检测插槽是否被安置在指示器上,通过监听AttachToPanelEvent
回调来判断是否被安插到了指示器上。
//声明一个私有变量_parent,指示器(如果没有指示器则为null)
private IndicatorElement _parent;
//创建一个工具函数RegisterCallbacks,在这里面进行注册回调。在构造函数中调用此函数
private void RegisterCallbacks(){//监听DetachFromPanelEvent,当插槽被从面板上分离时,将_parent设置为nullRegisterCallback<DetachFromPanelEvent>(_ =>{_parent = null;});//监听AttachToPanelEvent,当插槽被安插到面板上时,获取其父元素,若其父元素是IndicatorElement类型,则将其赋值给_parentRegisterCallback<AttachToPanelEvent>(_ =>{_parent = parent as IndicatorElement;});
}
添加一个带参构造函数,提供一个指示线
元素类型的参数,用于允许在构造时直接指定父级:
public SocketElement(IndicatorElement parent):this()
{_parent = parent;//下文将出现_opacity 的定义,为节省篇幅此处直接使用_opacity = _parent.style.opacity.value;
}
接下来通过对_parent
的判断,我们就可以实现插槽
与指示线
的属性联动了:
#region Propertiesprivate 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;elsestyle.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
属性AnimOpacity
、AnimPosition
、HideSocket
用到了experimental.animation
来进行动画差值,将会在下一节中进行介绍。
experimental.animation实现可控动画插值
⚠️此功能是实验性功能,未来可能会有所变化。
使用experimental.animation
来实现动画差值能够监听多种回调,从而做到更精确的控制。也能够直接进行纯数学插值回调(实现类似于DOTween的高级动画插值)。
若无需精确控制,则只需要使用
style.transitionProperty
、style.transitionDelay
、style.transitionDuration
、style.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);elseexperimental.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);elseexperimental.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
方法,在其中通过即时图形API
如Graphics.DrawTexture
、Graphics.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;});
}
⚠️注意:
我们使用了_context
的generateVisualContent
委托,因此需要调用_context
的MarkDirtyRepaint
动态字符标签 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
用于初始化Socke
的IndicatorElement
,SocketElement
:
private void InitializeSocketArray(){
//Socket已经在Unity Editor中分配了slotTransform、socketNameforeach (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>
的部分数据(transform
和socketName
)。
更新指示线让其总指向场景物体
ℹ️通过对
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;
}
注意其中的ellipticalCenter
、ellipticalA
、ellipticalAB
,是全局的变量:
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; // 更新前一点为当前点}
}
其中_mainCamera
为Camera.main
,请自行建立全局变量。
在LateUpdate
中进行绘制:
private void LateUpdate(){
#if DEBUG_DRAWDrawEllipse();
#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; }
}
下图是rate
从0调整到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。
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]);}
}
注意其中的WriteOnly
、ReadOnly
标识,若无标识,则表示变量是可读可写
的,合理运用标识可提高编译后代码的执行效率。
使用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,分配任务、获取结果
建立两个函数ScheduleJob
、TryGetResults
,以及相关变量,分别用于启动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会保证输入与输出相同索引所对应的元素一定相同。
在Update
与LateUpdate
分别中调用这两种方法:
private void Update()
{StartJob();// 原始更新方法:// TransformWorldToScreenCoordinates();// TransformSocketToEllipse(true);
}
private void LateUpdate()
{TryApplyJobResult();// 绘制Debug椭圆
#if DEBUG_DRAWDrawEllipse();
#endif
}
效率对比
极端情况,屏幕上有100个指示线:
未启用Job - 70FPS | 启用Job - 110FPS |
---|---|
更新椭圆匹配物体形状
所谓匹配形状,即是根据Renderer
的BoundingBox
,获取屏幕最小矩形,从而设置椭圆的长短轴大小。
获取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// 创建并返回 Rectreturn 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;}
}
在更新ellipticalA
与ellipticalB
时,我直接使用硬编码的方式,让其始终长100单位距离。这显然是欠妥的,不过安全区限制了其副作用,因此可以使用这种方式。
高级属性面板
ℹ️:本节给出一个元素的继承例子,最大化的复用相同代码。
⚠️:属性面板与我实现的武器属性遗传算法高度关联,而后者的实现复杂度远高于整篇文章,篇幅原因,我只给出UI的实现逻辑。
❌:本节代码仅供参考,无法在没有遗传算法的情况下发挥功能
属性面板的特别之处在于其是由多个子属性信息元素
组合而成,因此我们需要先实现子属性信息元素
,其中子属性信息元素
分为两类:
-
AttributeElement
:显示组件的固有属性,用于选择栏目中的属性预览。表现为文字+数字: -
InheritedAttributeElement
:显示组件的固有+被子组件影响的属性,用于武器的属性总览。表现为多个进度条:
这两类子属性信息元素
都继承于同一个基类AttributeValueElementBase
:
- 提供了基础信息展示:属性名称、属性说明文本、属性数值。
- 提供了基础事件:当鼠标移入时的事件(默认为展开属性说明文本)。
- 提供了虚函数,允许重写自定义属性名称、数值的更新显示逻辑。
考虑到InheritedAttributeElement
、AttributeElement
关键功能都是基于AttributeValueElementBase
虚函数实现的,在这里我必须给出AttributeValueElementBase
完整逻辑:
由于
AttributeValueElementBase
为抽象类,因此没有UxmlFactory
、UxmlTraits
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;}elseAttributeIndicatorManager.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
相同,重写OnRenderLabel
、OnUpdateValue
。并在其中处理进度条相关的逻辑,由于涉及到了属性值遗传计算,外加篇幅原因,在这里就不展开了。
属性来源指示器
ℹ️:通过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;}
}
注意三角形绕旋方向十分重要,反向的法向将被剔除,因此需要手动进行纠正。
更新网格形状
为了能够快速修改形状,添加两个函数UpdateData
、ShrinkUpdate
用于支持动画化的修改网格形状:
//延展矩形网格
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
无法做到预期效果,我们需要能够截取屏幕上的物体单独渲染图,并实时更新图片的效果。
为了便于管理这个一个过程,建立一个MonoBehaviour
:AttributeIndicatorManager
生成屏幕上物体的独立渲染图
首先在场景中新建一个摄像机,要求与主摄像机属性与位置完全一致,且设置为主摄像机的子级。但其剔除层要选择一个没有其他物体的空白层,用于单独渲染物体
添加属性:
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));}// 创建并返回 Rectreturn Rect.MinMaxRect(minScreenPoint.x, minScreenPoint.y, maxScreenPoint.x, maxScreenPoint.y);
}
使用方式
正如前一节InheritedAttributeElement
中Progress
的介绍,只需要:
AttributeIndicatorManager.SetIndicatorTarget(_progress.worldBound.min,_progress.worldBound.max,TargetComponent,Color
);
其中_progress.worldBound.min
,_progress.worldBound.max
组成了进度条的对角线。
关于武器属性遗传算法
由于篇幅原因,这里就不展开,视情况更新相关的解析教程。
文章如有不当之处,还望指正