上一节回顾
上一节我提出了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,
}
/// <summary>
/// 技能的基础轨道
/// </summary>
public abstract class Track
{
//名字
public string title;
//轨道的ID序号
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;
//上次的按压x坐标
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);
}
}
//处理轨道body上的事件 主要是对事件的操作
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;
/// <summary>
/// 动画轨道
/// </summary>
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:
{
//点击轨道body 用于添加事件
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,自己亲手思考写一遍,会记忆更加哦~