Unity运行时节点编辑器——互动电影案例
引子
最近需要做一个互动电影的小项目,需求很简单,就是有一堆的视频,然后在某视频播放完的时候,让观众做一个选择题,然后根据观众做出的选择,继续播放不同的视频,实现观看不同的结局的目的。于是,本着让策划傻瓜化使用的原则,就有了这个运行时的节点编辑器。
先来看看效果吧:
Unity制作运行时节点编辑器
其实之前就写过类似的功能:Unity Graph View打造图形化对话框编辑系统
虽然这个Graph View很方便,但是它只能运行在Unity的编辑模式,不能在“Unity运行时”进行编辑,至少我目前未找到如何运行时使用这个功能。所以全部都需要自己来整,幸好并不是很复杂。
技术点
- 数据描述。其实严格来说,当配置完成,处于运行模式的时候,这些节点的外观属性是不需要的,核心的数据只需要知道节点如何运行,以及节点的下一个接口是什么,参数节点的值是什么,这类似于一个“语法树”。然而,在编辑模式下,还需要额外增加编辑的属性,比如节点的位置之类的,这需要一个有效的数据结构。
- 节点的连线。没错,其实节点的连线远比我想想的复杂。可能有人第一感觉想到
LineRenderer
,但用LineRenderer
其实并不太适合,原因是,这些节点在UI层,需要被Mask,比如你的图在一个Scroll View
下面,超出去视图的部分,应该被遮蔽,而如果用LineRenderer
,这将是一件很麻烦的事。幸好,之前研究过在UGUI画线的方法(请参考Unity UGUI优雅的绘制线段)。还有另外一件事,就是线段的“拾取”,本来,我是使用“直线”来连接节点的,用直线不仅方便,性能好,而且拾取算法很容易写,用点到直线的距离公式即可,但是直线看上去不是那么“高大上”,然后就有了贝塞尔曲线版本,然后你判断鼠标是否点到了线上,就比较麻烦了,不过测试下来,性能还可以接受。
实现
- 基本的数据描述,这里只给出了关键的接口,具体实现太冗长了,而且并不复杂。
// 节点布局形式
public enum Layout
{
LeftOnly, // 只有左边的接口
RightOnly, // 只有右边的接口
Both // 左右都有接口
}
// 节点类型
public enum NodeType
{
Empty, // 空节点
Video, // 视频节点
Question, // 问题和玩家选择节点
String, // 字符串节点
Text, // 文本节点
// .... 将来扩充更多类型的节点,比如逻辑判断。。。
}
// 接口类型
public enum PortType
{
Input, // 程序走向,输入接口
Output, // 程序走向,输出接口
Params, // 参数接口(输入)
Value // 参数值接口(输出)
}
// 节点类(仅接口描述)
public class Nodebase
{
// 节点的位置
public Vector2 position { get; set; }
// 节点布局形式
public Layout layout { get; set; }
// 运行节点
public virtual void Run() {}
// 尝试获取节点的接口
public bool TryGetPort(string portName, out InterfacePort port);
// 获取节点的值(比如当节点是一个文本节点,则返回string类型的文本值)
public T GetParamsValue<T>();
// 创建节点
public InterfacePort CreatePort(string portName, PortType type,
// 节点列表
private Dictionary<string,InterfacePort> ports = new ();
}
public class InterfacePort
{
// 接口连线处的坐标
public Vector2 PortPosition { get; }
// 接口类型
public PortType Type { get; }
// 所属的节点
public Nodebase OwnerNode { get; }
// 建立链接
public void MakeLink(InterfacePort other);
// 清除指定链接
public void ClearLink(InterfacePort other);
// 清楚所有链接
public void ClearAlllink();
// 判断到指定的端口可否建立链接(类型判断等,比如两个输入端口不能连在一起)
public bool IsConnectable(InterfacePort target);
}
- 下面给出UGUI上连线的方案,原理请参考《Unity UGUI优雅的绘制线段》
[RequireComponent(typeof(CanvasRenderer))]
public class LineRendererOnGUI : MaskableGraphic, ICanvasRaycastFilter, IPointerClickHandler, IPointerEnterHandler, IPointerExitHandler
{
// 线的半径
[SerializeField] private float _Radius = 2f;
// 各类事件
public UnityEvent<LineRendererOnGUI> OnClick;
public UnityEvent<LineRendererOnGUI> OnPointerHover;
public UnityEvent<LineRendererOnGUI> OnPointerLeave;
// 半径
public float Radius
{
get => _Radius;
set
{
_Radius = value;
float v = _Radius + 2;
squareOfRadius = v * v;
}
}
private Vector2 A; // 点A
private Vector2 B; // 点B
private bool aIsRight; // 点A是否朝右
private bool bIsRight; // 点B是否朝右
private float squareOfRadius; // 半径的平方(判断点击用)
private RectTransform _Parent;
protected override void Awake()
{
base.Awake();
squareOfRadius = ( _Radius + 2 ) * ( _Radius + 2 );
_Parent = transform.parent.GetComponent<RectTransform>();
}
public void SetStartPos(Vector2 pos, bool bRight, bool bUpdate = true)
{
A = pos;
aIsRight = bRight;
if (bUpdate)
RebuildLine();
}
public void SetEndPos(Vector2 pos, bool bRight, bool bUpdate = true)
{
B = pos;
bIsRight = bRight;
if (bUpdate)
RebuildLine();
}
private float delta; // 精细度
// 重建贝塞尔
private void RebuildLine()
{
float h = Mathf.Abs(A.x - B.x) * 0.5f;
Vector2 C, D;
C.y = A.y;
D.y = B.y;
C.x = aIsRight ? A.x + h : A.x - h;
D.x = bIsRight ? B.x + h : B.x - h;
float dis = Vector2.Distance(A, B);
delta = 1f / ( dis * 0.125f ); // 每8个像素增加一个细节,delta越小越精细
positions.Clear();
for (float t = 0; t <= 1f; t += delta)
{
float st = 1f - t;
float st2 = st * st;
float t2 = t * t;
Vector2 p = st2 * st * A + 3 * st2 * t * C + 3 * st * t2 * D + t2 * t * B;
positions.Add(p);
}
//SetVerticesDirty();
//SetRaycastDirty();
SetAllDirty();
}
private readonly List<Vector2> positions = new();
protected override void OnPopulateMesh(VertexHelper vh) // 构造线段
{
if (positions.Count <= 1)
{
base.OnPopulateMesh(vh);
return;
}
vh.Clear();
int count = positions.Count;
int csub = count - 1;
for (int i = 0; i < count; ++i)
{
int ia1 = i + 1;
int is1 = i - 1;
if (i == 0)
{
FiristPoint(positions[i], positions[ia1], vh);
}
else if (i == csub)
{
LastPoint(positions[is1], positions[i], vh);
}
else
{
MidPoint(positions[is1], positions[i], positions[ia1], vh);
}
if (i > 0)
{
int id2 = i << 1;
int is1d2 = is1 << 1;
int is1d2a1 = is1d2 + 1;
vh.AddTriangle(is1d2, id2, is1d2a1);
vh.AddTriangle(is1d2a1, id2, id2 + 1);
}
}
}
private static readonly Quaternion orthogonality = Quaternion.AngleAxis(90, Vector3.forward);
private void FiristPoint(Vector2 cur, Vector2 next, VertexHelper vh)
{
Vector2 left = (orthogonality * (next - cur)).normalized;
vh.AddVert(cur + left * Radius, color, Vector2.zero);
vh.AddVert(cur + left * -Radius, color, Vector2.zero);
}
private void LastPoint(Vector2 prev, Vector2 cur, VertexHelper vh)
{
Vector2 left = (orthogonality * (cur - prev)).normalized;
vh.AddVert(cur + left * Radius, color, Vector2.zero);
vh.AddVert(cur + left * -Radius, color, Vector2.zero);
}
/// <summary>
/// 处理中间节点
/// </summary>
/// <param name="prev">上一个顶点</param>
/// <param name="cur">当前顶点</param>
/// <param name="next">下一个顶点</param>
/// <param name="vh">顶点管理器</param>
private void MidPoint(Vector2 prev, Vector2 cur, Vector2 next, VertexHelper vh)
{
Vector2 left1 = (orthogonality * (cur - prev)).normalized;
Vector2 left2 = (orthogonality * (next - cur)).normalized;
Vector2 left = ((left1 + left2) * 0.5f).normalized;
float a = Vector2.Angle(left1, left2) * Mathf.Deg2Rad * 0.5f;
float r = Radius / Mathf.Cos(a);
vh.AddVert(cur + left * r, color, Vector2.zero);
vh.AddVert(cur + left * -r, color, Vector2.zero);
}
// 射线击中过滤,
public bool IsRaycastLocationValid(Vector2 sp, Camera eventCamera)
{
if (raycastTarget && RectTransformUtility.ScreenPointToLocalPointInRectangle(_Parent, sp, eventCamera, out Vector2 lp))
{
float h = Mathf.Abs(A.x - B.x) * 0.5f;
Vector2 C, D;
C.y = A.y;
D.y = B.y;
C.x = aIsRight ? A.x + h : A.x - h;
D.x = bIsRight ? B.x + h : B.x - h;
for (float t = 0; t <= 1f; t += delta)
{
float st = 1f - t;
float st2 = st * st;
float t2 = t * t;
Vector2 p = st2 * st * A + 3 * st2 * t * C + 3 * st * t2 * D + t2 * t * B;
if ((lp - p).sqrMagnitude < squareOfRadius)
return true;
}
}
return false;
}
public void OnPointerClick(PointerEventData eventData)
{
OnClick?.Invoke(this);
}
public void OnPointerEnter(PointerEventData eventData)
{
OnPointerHover?.Invoke(this);
}
public void OnPointerExit(PointerEventData eventData)
{
OnPointerLeave?.Invoke(this);
}
}
关于源码
这个项目目前还没做完,涉及到运行时问问题的界面还需要策划和美术去敲定,所以目前,问题节点的运行还没有实际做完,但是作为一个抛砖引玉的东西,用来研究一下思路还是可以滴。另外,代码写的有点乱,因为思路变了好几次,所以工程源码还有很多可以改进的地方。最后,不建议小白去下载。一定想下载研究的话,请一定看前面这段话,三思之后再下载。
点击下载源码