上一节回顾
上一节我提出了3个问题。 1.动画结束,技能就结束了吗? 2.1个技能就只有一个动画吗? 3.这些轨道是否足以完成技能? 1.动画结束技能不一定结束,技能是有自己的时间轴的。 比如LOL剑圣的W冥想,很明显剑圣的冥想动作是个循环的,那么我们怎么确定时间呢?策划又是如何能确定回多少秒血呢,这个只能依靠设定一个技能时间,然后在这个时间轴上去设定HitEvent(碰撞事件)来设定回血次数。 2.1个技能只有一个动画吗? 上面已经回答了,技能与动画无关了,动画只是技能的一个部分,那么说明了技能与动画是1对n的关系的。比如很多RPG会有绝杀招,这种绝杀招一般都是多段动作,比如疯狂的劈砍前方,然后抓取回来,最后挑飞,一刀劈开大地,想想是不是帅的雅痞,这种就是多个动画去组合成了一个技能的。 3.我上节提到的轨道足以完成绝大部分的技能。 比如很经典的素质三连,我相信任何游戏这个,比如0cd起手技能,左一刀、右一刀、最后空中转体一刀,这个就是可以在每个技能里面去监听消息,来完成转换。又或者LOL里面的剑姬W,第一段进入格挡状态,如果在格挡状态期间监听到眩晕消息,那么立马切换状态进入反击状态,同时反击状态附带控制,如果没有接受到眩晕消息,那么等格挡状态结束进入自动进入反击状态,此刻反击不带眩晕。但是比如盲僧那种的2段Q,就需要小小的处理一下了,因为你会发现盲僧Q出手后,已经回到了待机状态了,但是Q飞出去了,这个时候Q是一个飞行物弹道飞出去了,那么当命中物体时候,此刻再按下2段Q是在输入层的激活2段Q的输入信息,再次按下会跳转到2段Q追击状态。 上述讲解的其实就是我这个技能编辑器和框架实现一些技能的思路,后面会具体的实现。
轨道的职责划分
好了回到编辑器开发上,上一次中我已经教会了大家把窗体框架搭起来了,现在大家手上应该有很多空空的主函数。这一节就是来分析轨道。老规矩,先上图: 无视那些按钮,我们会发现一条条的Track很显眼,这个就是技能很核心的事件轨道轴了,每个轨道上能够配置各种事件,比如Animation Track上面就可以配置哪些动画,HitEvent Track上面就可以配置哪些碰撞盒范围。那么我们来提取一下这个Track具有哪些行为,1 绘制 2 处理事件。其实和之前的窗体架构没区别,那么我们首先写个Track 基类 ,代码如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
public enum TrackType
{
Animation,
Audio,
Effect,
Camera,
Hit,
Bullet,
Interrupt,
End,
}
public abstract class Track
{
public string title;
public int ID;
public Rect headerRect;
public Rect bodyRect;
public GUIStyle headerStyle;
public GUIStyle bodyStyle;
public Color backColor;
public List<StateEvent> allStateEvents;
public List<AnimationEvent> animationEvents;
public List<EffectEvent> effectEvents;
public List<MessageEvent> messageEvents;
public List<HitEvent> hitEvents;
protected bool isEventDragged = false;
protected StateEvent currtentSelected = null;
protected float lastPosX;
#if UNITY_EDITOR
public virtual void DrawHeader()
{
EditorGUI.DrawRect(headerRect, backColor);
GUI.Box(headerRect, title);
}
public virtual void DrawBody()
{
Color color = backColor;
color.a = 0.8f;
EditorGUI.DrawRect(bodyRect, color);
}
public void DrawEvents(float offsetX,float offsetY,float timeScale)
{
foreach(var evt in allStateEvents)
{
evt.DrawEvent(offsetX,offsetY, timeScale);
}
}
public abstract void ProcessBodyEvent(Event evt);
public abstract void ProcessHeaderEvent(Event evt);
public void RefreshHeaderRect(Rect rect)
{
this.headerRect = rect;
}
public void RefreshBodyRect(Rect rect)
{
this.bodyRect = rect;
}
#endif
}
上面代码我们可以看到首先有一个枚举类型,用来区分不同的轨道,然后轨道基类里面我们可以看到有2个核心的Rect,我之前说过了技能编辑器里面Rect很重要,需要依靠它计算事件和渲染。那么这两个Rect首先能满足渲染头部和轨道身体了,头部就是图中的那个带数字的,身体就是时间轴那部分,因为轨道的渲染其实本质会发现一个轨道就是2个Rect组成,因此我调用了**EditorGUI.DrawRect(headerRect, backColor);**这个就是绘制一个矩形区域。那么渲染就解决了。而处理事件我是采用的抽象方法,在子类中具体实现的,代码如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
public class AnimationTrack :Track
{
public AnimationTrack(int index,GUIStyle headerStyle,GUIStyle bodyStyle,Color backColor)
{
ID = index;
this.headerStyle = headerStyle;
this.bodyStyle = bodyStyle;
this.backColor = backColor;
title = $"Animation Track";
allStateEvents = new List<StateEvent>();
animationEvents = new List<AnimationEvent>();
}
public override void ProcessBodyEvent(Event evt)
{
switch (evt.type)
{
case EventType.MouseDown:
{
Vector2 pointer = evt.mousePosition;
pointer.x -= (SkillEditorWindow.styles.headerRect.width + SkillEditorWindow.styles.inspectorRect.width);
if (evt.button == 1
&& bodyRect.Contains(pointer))
{
ProcessAddEvent(evt.mousePosition);
SkillEditorWindow.currtentSelectd = null;
}
if(evt.button == 0)
{
foreach(var temp in allStateEvents)
{
if (temp.eventRect.Contains(pointer))
{
lastPosX = evt.mousePosition.x;
currtentSelected = temp;
SkillEditorWindow.currtentSelectd = currtentSelected;
isEventDragged = true;
evt.Use();
}
}
}
}
break;
case EventType.MouseUp:
{
isEventDragged = false;
}
break;
case EventType.MouseDrag:
{
if (isEventDragged)
{
if (SkillEditorWindow.isPlay)
return;
float delta = evt.mousePosition.x - lastPosX;
currtentSelected.DragEvent(delta,SkillEditorWindow.timeScale);
lastPosX = evt.mousePosition.x;
evt.Use();
}
}
break;
}
}
private void ProcessAddEvent(Vector2 postion)
{
GenericMenu genericMenu = new GenericMenu();
genericMenu.AddItem(new GUIContent("Add AnimationEvent Trrigger"), false, () => OnClickAddTrriggerEvent(postion));
genericMenu.AddItem(new GUIContent("Add AnimationEvent Duration"), false, () => OnClickAddDurationEvent(postion));
genericMenu.ShowAsContext();
}
private void OnClickAddTrriggerEvent(Vector2 postion)
{
Rect rect = new Rect();
rect.x = postion.x - (SkillEditorWindow.styles.actionsRect.width + SkillEditorWindow.styles.headerRect.width) - SkillEditorWindow.timeLineOffsetX;
rect.y = bodyRect.y;
rect.width = 4f;
rect.height = SkillEditorWindow.trackHeight;
AnimationEvent evt = new AnimationEvent(rect, StateEventType.EventTrigger, SkillEditorWindow.timeScale, SkillEditorWindow.animationClips, SkillEditorWindow.animationClipsName, SkillEditorWindow.animationClipsSelect);
animationEvents.Add(evt);
allStateEvents.Add(evt);
}
private void OnClickAddDurationEvent(Vector2 postion)
{
Rect rect = new Rect();
rect.x = postion.x - (SkillEditorWindow.styles.actionsRect.width + SkillEditorWindow.styles.headerRect.width) - SkillEditorWindow.timeLineOffsetX;
rect.y = bodyRect.y;
rect.width = 4f;
rect.height = SkillEditorWindow.trackHeight;
AnimationEvent evt = new AnimationEvent(rect, StateEventType.EventTrigger, SkillEditorWindow.timeScale, SkillEditorWindow.animationClips, SkillEditorWindow.animationClipsName, SkillEditorWindow.animationClipsSelect);
animationEvents.Add(evt);
allStateEvents.Add(evt);
}
public override void ProcessHeaderEvent(Event evt)
{
switch (evt.type)
{
case EventType.MouseDown:
{
Vector2 pointer = evt.mousePosition;
pointer.x -= SkillEditorWindow.styles.inspectorRect.width;
if (evt.button == 1
&& headerRect.Contains(pointer))
{
ProcessAddTrack(evt.mousePosition);
}
}
break;
}
}
private void ProcessAddTrack(Vector2 postion)
{
GenericMenu genericMenu = new GenericMenu();
genericMenu.AddItem(new GUIContent("Add AnimationTrack"), false, () => OnClickAddTrack(postion));
genericMenu.AddItem(new GUIContent("Delete this Track"), false, () => OnClickDeleteTrack(postion, ID));
genericMenu.ShowAsContext();
}
private void OnClickAddTrack(Vector2 postion)
{
SkillEditorWindow.InitSingleHeader(TrackType.Animation,ref SkillEditorWindow.startIndex);
}
private void OnClickDeleteTrack(Vector2 postion, int index)
{
SkillEditorWindow.DeleteHeader(TrackType.Animation, index);
}
}
可以看到我的处理事件是接受到一个参数Event,这个是什么地方来的?这个就是我们之前写的SkillEditorWindows里面来的,这个后续介绍。我们进入函数内部会发现就是监听一些常见的鼠标按下,拖拽这些,你会发现Rect起到了很大的作用,我们鼠标点下时候是能够get到坐标的,那么我们就能根据鼠标点下的位置,使用Rect.Contain(vector2),判断是否点击在这个Rect内,其实就是我们是不是点中了这个区域,这样我们是不是就可以实现,比如点击头部区域,去new 一个新轨道,点击身体区域去new一个Event呢。这里有个非常要注意的一点,我在一开始就说过了rect的坐标是相对的,而我们一开始又是划分了很多布局的Rect,那么此刻轨道的头部Rect看起来似乎是在中间,其实是(0,0)因为他位于父Rect的左上角原点,而鼠标点在Imgui中的时候是以最开始划分的窗体Rect就是整个窗口为坐标系的,所以可以看到我代码中是把鼠标点击的坐标进行的偏移的,这个点非常重要,理解这个偏移,后续时间轴很多的操作,比如缩放,无限滑动都和这个相对坐标和偏移概念有关。 大家也可以发现我的代码里面去处理轨道上的事件的事件的时候是直接调用事件去处理的,这就是我今天在本节中最想提出的一个概念,关注点分离,很多人写不来技能编辑器,就是不知道从何下笔,可以看到我如何处理轨道的,就是放在轨道上面,轨道本身关注自己的业务逻辑,而轨道上的事件关注自己的逻辑,而技能编辑器窗口关注自己的逻辑,划分Rect,传递事件,这样事件就一层层传下去。后续包括大家新增轨道类型,比如发现技能中还有一些事件可以提取一个轨道,那么只需要新加一个轨道类型,去继承自Track,自己去处理对应轨道上面的绘制和逻辑即可。这样就可以把复杂的功能,拆分成很多单一的模块。 今天主要是介绍了轨道的划分,这里面很多的东西,大家可以仔细思考一下。教程讲解到现在,其实大家应该绘制出了一个技能编辑器的大雏形了, 下一节介绍如何写时间轴的编辑器逻辑。本节的问题就是,大家自己把之前讲解的窗体的那个框架和这个Track结合起来,完成绘制Track,遇到一些添加事件可以打出Debug,自己亲手思考写一遍,会记忆更加哦~
|