6.自研表现序列系统设计
6.1 为什么不用 Timeline 做技能 / 表现层
前面五篇围绕 Timeline 讲了不少:轨道体系、Director API、建图源码、过场编辑器。Timeline 在过场演出场景里确实好用——可视化编排、Clip 混合、权重求值、嵌套子 Timeline,这些能力让导演向的内容制作效率很高。
但换到技能表现 / 通用表现序列这个场景就不太合适了。实际项目里我们最终没有用 Timeline 做技能表现,而是自己写了一套类 Timeline 的表现编辑器。原因有几个:
不需要混合
技能表现和过场演出最大的区别是:过场需要混合,技能不需要。过场里两个动画 Clip 之间有 MixIn / MixOut 过渡,Cinemachine 镜头之间有 Blend,这些都依赖 PlayableGraph 的权重求值体系。但技能表现里,一个动作从第 0 毫秒播到第 1200 毫秒、一个特效从第 200 毫秒挂到第 800 毫秒,它们之间一般是纯并行关系,不存在权重混合的需求。为了这种场景去建一张 PlayableGraph,有点杀鸡用牛刀。
配置轻量,迭代快
Timeline 的 .playable 文件本质是 ScriptableObject,内部是一套完整的序列化结构——TrackAsset、ClipAsset、PlayableBinding、各种 GUID 引用链,用文本编辑器打开就是一堆看不懂的 YAML。改一个 Clip 的参数,要在 Unity 编辑器里操作,保存后整个 .playable 重新序列化。部署热更的话,不管是 ScriptableObject 走 AssetBundle 还是 Lua 文件走 AB / 文件下发,都得过一遍打包流程,这点两边差不多。
但区别在于配置本身的重量。技能表现的配置说白了就是”第几毫秒做什么事”,一个火球技能可能就 3 个节点、20 行配置。用 Lua table 或 JSON 存的话,人眼一扫就能看懂,手改一个数字都行。用 .playable 存的话,这 20 行信息会散落在一堆 TrackAsset / ClipAsset 的 GUID 引用里,文件体积和复杂度高了一个数量级。开发期间每次调参数都要在 Timeline 窗口里操作、等序列化、等编译——技能类型多了以后,这个迭代效率的差距会越来越明显。
运行时要轻量
PlayableGraph 虽然性能不差,但它毕竟是一个完整的求值框架——Create Graph、Create Playable、Connect、SetWeight、每帧 PrepareFrame + ProcessFrame,有自己的一套生命周期管理。技能表现的需求更简单一些:按时间轴推进,到了某个时间点就触发对应的逻辑(播动画、挂特效、播音效……),不需要 Graph 那套东西。一个 Update() 里遍历节点、比较时间就够了。
节点类型爆炸但逻辑简单
过场编辑器里的自定义轨道不算多(对话、字幕、特效、音效、幕帘……十来种),但每种轨道要处理 Mixer 混合、Binding 绑定、编辑器预览,写起来有一定复杂度。技能表现的情况正好相反:节点类型特别多(动画、特效、音效、相机、移动、UI、隐藏、描边、缩放、子序列……几十种),但每种节点的逻辑都很直接——开始时做什么、结束时撤销什么。用 Timeline 的话,每种节点都要写 TrackAsset + PlayableAsset + PlayableBehaviour + MixerBehaviour 四件套,光样板代码就受不了。自己做一套的话,继承一个基类重写几个方法就行。
小结
| Timeline 过场编辑器 | 自研表现编辑器 | |
|---|---|---|
| 底层 | PlayableGraph 求值框架 | 自己的 Update 循环 |
| Clip 混合 | 有(MixIn / MixOut / 权重) | 无,纯并行 |
| 数据格式 | .playable(重型序列化,GUID 引用链) | Lua table / JSON(扁平文本,人可读) |
| 迭代效率 | 在 Timeline 窗口操作,改完等序列化 | 文本编辑器直接改,改完直接加载 |
| 新增节点成本 | TrackAsset 四件套 | 继承基类重写几个方法 |
| 适用场景 | 导演向演出(过场、CG) | 技能表现、交互动作、通用表现序列 |
两套方案不是互斥的,完全可以在同一个项目里并存——过场用 Timeline,技能和交互动作用自研编辑器,各管各的场景。自研编辑器里甚至可以加一种 PlayCutsceneNode,在表现序列里触发 Timeline 过场,两套系统互相打通。
6.2 整体架构
三层结构
整套系统分三层:编辑器负责可视化编排和数据保存,数据层是纯文本配置文件,运行时按时间驱动节点执行。编辑器一定是 C#(Unity Editor 扩展),数据层和运行时用什么语言都行。后文以 Lua 方案为例来讲,但设计思路是语言无关的——换成 C# + JSON 只是序列化方式不同,架构不变。后面不再逐处说明这一点。
graph TB
subgraph editor["编辑器 (C# / UIToolkit)"]
A["ActionSequenceEditorWindow
UIToolkit 时间轴界面"]
B["ActionTrackView / TrackItem
轨道和片段渲染"]
C["ActionEditorInspector
节点参数面板"]
end
subgraph data["数据层 (纯文本配置)"]
D["ActionSequenceData
结束类型 + 节点列表"]
end
subgraph runtime["运行时"]
E["ActionSequencePlayer
管理多个序列的播放/停止/打断"]
F["ActionSequence
单个序列实例,驱动时间推进"]
G["ActionNode 子类
PlayAnimNode / PlayEffectNode / ..."]
end
A -->|"序列化写文件"| D
A -->|"解析文件加载"| D
D -->|"运行时加载配置"| F
E -->|"PlayAction() 创建序列"| F
F -->|"ActionNodeFactory 按 type 创建"| G
核心类一览
| 类名 | 层 | 职责 | 类比 Timeline |
|---|---|---|---|
ActionSequenceData |
数据层 | 序列配置数据,包含节点列表、结束类型 | TimelineAsset |
ActionSequence |
运行时 | 运行时序列实例,驱动时间推进和节点生命周期 | TimelinePlayable |
ActionSequencePlayer |
运行时 | 管理同一对象上的多个序列,处理播放/停止/打断 | PlayableDirector |
ActionNode |
运行时 | 节点基类,定义生命周期回调 | PlayableBehaviour |
ActionNodeFactory |
运行时 | 按类型字符串动态创建节点实例 | 无直接对应 |
ActionSequenceEditorWindow |
编辑器 | UIToolkit 编辑器窗口,解析/保存配置 | Timeline 窗口 |
类图如下,可以和 Timeline 的对应概念对照着看:
classDiagram
class ActionSequencePlayer {
<<序列管理器,每个需要播表现的对象持有一个>>
-uidToSequence : Dictionary~string, ActionSequence~ 按 uid 查找序列实例
-layerToSequence : Dictionary~int, ActionSequence~ 按 layer 查找,同层只保留一个
-runningUidList : List~string~ 有序 uid 列表,保证遍历顺序
+PlayAction(dataId, endCallback) uid 播放一个序列,返回唯一 id
+StopActionById(dataId) 按配置 id 停止所有同名序列
+RemoveAction(uid) 按 uid 移除并销毁一个序列
-TryBreakAction(sequence) 层级打断:同 layer 新序列顶掉旧序列
+Dispose() 销毁所有正在播放的序列
}
class ActionSequence {
<<单个序列实例,驱动时间推进和节点生命周期>>
+uid : string 唯一标识
+dataId : string 对应的配置 id
+playingTime : number 当前播放时间(毫秒)
+duration : number 序列总时长,取所有节点 endTime 的最大值
+endType : number 结束类型:1=Destroy 2=Wait 3=Loop
+nodes : ActionNode[] 本次播放创建的所有节点实例
+Start() 开始播放,立刻触发一次 Update
+Update() 每帧调用,遍历节点判断时间区间并触发回调
+OnTimeEnd() 时间走完时根据 endType 决定销毁/停留/循环
+RestartPlay() 重置时间从头播放(Loop 时调用)
+Dispose() 销毁所有节点,清理引用
}
class ActionNode {
<<节点基类,定义五个生命周期回调>>
+data : table 该节点的配置数据(startTime / endTime / param 等)
+isStarted : boolean 是否已触发过 OnPlay
+isDestroyed : boolean 是否已被销毁
+sequence : ActionSequence 所属序列的引用
#OnCreate() 序列实例化时调用,整个生命周期只一次
#OnPlay(passTime) 时间进入节点区间时触发,passTime 为已过毫秒数
#OnUpdate(passTime) 节点激活期间每帧调用
#OnEnd() 时间离开节点区间时触发,做清理
#OnDestroy() 序列销毁时调用,释放资源
}
class PlayAnimNode {
<<播放动画>>
#OnPlay(passTime) 通知模型播放指定动画
#OnEnd() 取消动画,触发过渡
}
class PlayEffectNode {
<<播放特效>>
#OnPlay(passTime) 创建特效挂到骨骼上
#OnEnd() 销毁特效
}
class PlaySoundNode {
<<播放音效>>
#OnPlay(passTime) 播放音效事件
#OnEnd() 根据配置决定是否停止音效
}
ActionSequencePlayer "1" --> "*" ActionSequence : 一个管理器驱动多个并发序列
ActionSequence "1" --> "*" ActionNode : 一个序列持有一组节点
ActionNode <|-- PlayAnimNode : 继承
ActionNode <|-- PlayEffectNode : 继承
ActionNode <|-- PlaySoundNode : 继承
6.3 数据层:配置格式
数据结构
一个表现序列的配置本质就是一个扁平的数据文件。核心就是:结束类型 + 一组节点,每个节点有起止时间(毫秒)、类型字符串和参数:
-- ActionData/Act_Skill_FireBall.lua
return {
actionEndType = 1, -- 1=Destroy 2=Wait 3=Loop
node = {
{
startTime = 0,
endTime = 1200,
type = "PlayAnimNode",
endNotAction = false,
param = {
animName = "Attack_Fire",
layerId = 0,
}
},
{
startTime = 300,
endTime = 800,
type = "PlayEffectNode",
endNotAction = false,
param = {
effectId = 10023,
bindBone = "Bip_RHand",
}
},
{
startTime = 400,
endTime = 600,
type = "PlaySoundNode",
endNotAction = false,
param = {
soundEvent = "Fire_Cast",
}
},
},
}
结构很扁平,一个序列就是一组按时间排列的节点,没有轨道、没有 Clip 混合、没有 Graph 连线。对比 Timeline 的 .playable 文件(YAML 格式,里面有 TrackAsset 引用链、ClipAsset GUID、PlayableBinding 配置),数据量和复杂度都低一个数量级。
运行时加载也很简单。Lua 项目里一行 require 就拿到整张表:
-- ActionDataUtil.lua
function ActionDataUtil.GetActionData(actionId)
return DataMgr:GetRes("ActionData.Act_" .. actionId)
end
正常流程下这些配置文件都是编辑器保存出来的,不需要手写。但因为是纯文本,如果线上发现某个技能配置有问题,可以直接打开 Lua 文件手改一个数字然后热更出去,不用回编辑器重新操作一遍。开发期间也一样,清一下缓存重新 require 就能热重载,改完立刻看效果。
节点参数元数据
不同节点的 param 结构不一样——PlayAnimNode 有 animName 和 layerId,PlayEffectNode 有 effectId 和 bindBone。编辑器需要知道每种节点有哪些参数、什么类型,才能自动生成 Inspector 面板。
问题是参数元数据从哪来。C# 项目可以用自定义 Attribute + 反射,Lua 没有反射,一种做法是在每个节点的 Lua 文件头部用注释块声明:
-- PlayAnimNode.lua
--- ActionNode custom param begin
---@field animName string 动画名称
---@field layerId int 动画层级
---@field endTransitionTime int 结束过渡时间
--- ActionNode custom param end
--- Chinese Name 播放动画
local ActionNode = require("ActionNode")
local PlayAnimNode = Class("PlayAnimNode", ActionNode)
-- ...
--- ActionNode custom param begin / end 之间的 ---@field 行声明了参数名、类型和中文描述。编辑器端(C#)有一个 LuaParamParser,负责解析这些注释。做法很直接:用 File.ReadAllLines 把整个 Lua 文件读进来,然后逐行扫描。用一个 bool inParamBlock 标记当前是否在 begin / end 注释块内——扫到 begin 就开始收集,扫到 end 就停止,中间每碰到一行 ---@field,就用正则把字段名、类型、中文描述三个部分拆出来,存进一个列表。另外还会顺带提取 --- Chinese Name 后面的中文名(比如”播放动画”),用于编辑器轨道上的显示。
// LuaParamParser.cs
public static class LuaParamParser
{
public static NodeParamMeta ParseLuaFile(string luaFilePath)
{
// 读取整个 Lua 文件的所有行
string[] lines = File.ReadAllLines(luaFilePath);
// NodeParamMeta:一个节点的完整参数元数据(中文名 + 字段列表)
NodeParamMeta nodeParamMeta = new NodeParamMeta();
// NodeParamFieldMeta:单个参数字段的元数据(字段名、类型、中文描述)
List<NodeParamFieldMeta> fieldMetaList = new List<NodeParamFieldMeta>();
bool inParamBlock = false; // 是否在 begin/end 注释块内
// 逐行扫描
foreach (string line in lines)
{
string trimmed = line.Trim();
// 碰到 begin 标记,开始收集参数
if (trimmed.StartsWith("--- ActionNode custom param begin"))
{ inParamBlock = true; continue; }
// 碰到 end 标记,停止收集
if (trimmed.StartsWith("--- ActionNode custom param end"))
{ inParamBlock = false; continue; }
// Chinese Name 在注释块末尾,扫到它说明元数据已经全了,提取后直接 break
if (trimmed.StartsWith("--- Chinese Name"))
{
nodeParamMeta.ChineseName = trimmed.Replace("--- Chinese Name", "").Trim();
break;
}
// 在注释块内,解析每一行 ---@field
// 例:---@field animName string 动画名称
// → name="animName", type="string", description="动画名称"
if (inParamBlock && trimmed.StartsWith("---@field"))
{
// 去掉 "---@field " 前缀,剩下的用正则拆成三段
Match match = Regex.Match(
trimmed.Replace("---@field", "").Trim(),
@"^(\w+)\s+(\w+)\s+(.+)$");
if (match.Success)
{
fieldMetaList.Add(new NodeParamFieldMeta
{
name = match.Groups[1].Value, // 字段名
type = NormalizeType(match.Groups[2].Value), // 类型(统一成 int/string/bool 等)
description = match.Groups[3].Value, // 中文描述
});
}
}
}
nodeParamMeta.NodeParamFieldMetaList = fieldMetaList;
return nodeParamMeta;
}
}
解析出来的结果会包装成一个 NodeParamMeta 对象(里面就是节点的中文名 + 一个 List<NodeParamFieldMeta>,每个 field 记录字段名、类型、中文描述),按节点类型缓存起来,同一种节点只解析一次。编辑器窗口初始化时还会用 .NET 的 FileSystemWatcher 监听节点 Lua 文件目录,文件一改动就清缓存,下次取参数时自动重新解析——具体实现在 6.5 编辑器部分展开。
程序加一种新节点只需要写一个 Lua 文件,头部按约定写几行注释声明参数,编辑器面板就能自动显示和编辑这些参数,不用额外写任何 Editor 代码。
6.4 运行时
总览
运行时一共四个角色:
- ActionSequencePlayer:序列管理器,每个需要播表现的对象持有一个,管理多个并发序列,处理层级打断
- ActionSequence:一次播放的实例,每帧推进时间,遍历节点判断时间区间并触发回调
- ActionNode:节点基类,定义五个生命周期回调(
OnCreate→OnPlay→OnUpdate→OnEnd→OnDestroy),子类只需重写需要的 - ActionNodeFactory:节点工厂,根据配置里的
type字符串动态创建对应的节点实例
graph TB
subgraph player["ActionSequencePlayer(序列管理器)"]
P["每个需要播表现的对象持有一个
管理多个并发序列,处理层级打断
同层互斥,不同层共存"]
end
subgraph sequence["ActionSequence(序列实例)"]
S["对应一次播放
每帧 Update 推进时间
遍历节点判断时间区间触发回调"]
end
subgraph factory["ActionNodeFactory(节点工厂)"]
F["根据 type 字符串动态创建节点
首次 require 后缓存,无需手动注册"]
end
subgraph node["ActionNode 子类"]
N1["PlayAnimNode
播放/取消动画"]
N2["PlayEffectNode
创建/销毁特效"]
N3["PlaySoundNode
播放/停止音效"]
N4["...80+ 种节点"]
end
P -->|"PlayAction() 创建"| S
S -->|"按 type 调工厂"| F
F -->|"返回节点实例"| node
S -->|"每帧按时间驱动
OnPlay / OnUpdate / OnEnd"| node
ActionNode:节点基类
先从最底层的 ActionNode 说起。Timeline 里 PlayableBehaviour 有一套比较复杂的回调体系(OnGraphStart / OnBehaviourPlay / PrepareFrame / ProcessFrame / OnBehaviourPause / OnGraphStop),我们的节点生命周期简单得多:
stateDiagram-v2
[*] --> Created: 序列实例化时
Created --> Playing: 时间推进到 startTime
Playing --> Playing: 每帧 OnUpdate
Playing --> Ended: 时间推进到 endTime
Ended --> Playing: 循环播放时重新触发
Ended --> Destroyed: 序列结束/被移除
Created --> Destroyed: 序列被提前移除
五个回调:
| 回调 | 触发时机 | 典型用途 |
|---|---|---|
OnCreate() |
序列实例化时,整个生命周期只调一次 | 预加载资源、缓存引用 |
OnPlay(passTime) |
当前时间进入 [startTime, endTime) 区间 | 播动画、创建特效、播音效 |
OnUpdate(passTime) |
节点处于激活状态的每一帧 | 更新移动位置、检查条件 |
OnEnd() |
当前时间离开 [startTime, endTime) 区间 | 停动画、销毁特效、停音效 |
OnDestroy() |
序列被销毁时,整个生命周期只调一次 | 释放资源、清理引用 |
基类伪代码:
-- ActionNode.lua
---@class ActionNode
local ActionNode = Class("ActionNode")
----------------------------------------------------------------------
-- 构造:序列创建节点实例时调用
-- index 节点在序列中的序号(从 1 开始)
-- data 这个节点在配置里的原始数据(startTime / endTime / type / param …)
-- sequence 所属的 ActionSequence 实例,子类可以通过它拿到 modelContainer 等上下文
----------------------------------------------------------------------
function ActionNode:Ctor(index, data, sequence)
self.index = index
self.data = data
self.sequence = sequence
self.isStarted = false -- 标记:是否已进入激活状态(OnPlay 已调用)
self.isDestroyed = false -- 标记:是否已被销毁(OnDestroy 已调用)
end
----------------------------------------------------------------------
-- 以下 Behaviour 系列方法由 ActionSequence 在合适的时机调用,
-- 内部再转发给子类的 On 系列虚方法。子类不要重写 Behaviour 方法。
----------------------------------------------------------------------
-- 序列实例化时调用,整个生命周期只一次
function ActionNode:BehaviourCreate()
self:OnCreate()
end
-- 时间进入 [startTime, endTime) 区间时调用
-- passTime:从 startTime 算起已经过了多少毫秒(中途跳入时 > 0)
function ActionNode:BehaviourPlay(passTime)
if self.isDestroyed then return end
self.isStarted = true
self:OnPlay(passTime)
end
-- 节点处于激活状态的每一帧调用
function ActionNode:BehaviourUpdate(passTime)
self:OnUpdate(passTime)
end
-- 时间离开节点区间时调用
function ActionNode:BehaviourEnd()
-- endNotAction = true → 结束时跳过 OnEnd(比如一次性音效,播完自己就停了)
if self.isStarted and not self.data.endNotAction then
self:OnEnd()
end
self.isStarted = false
end
-- 序列整体销毁时调用(兜底清理,确保资源不泄漏)
function ActionNode:BehaviourDestroy()
if self.isStarted then
-- 情况 A:节点还在激活中就被销毁了,先做一次 End 清理
self:OnEnd()
elseif self.data.endNotAction then
-- 情况 B:endNotAction = true 的节点,正常结束时不需要清理
-- 比如一个"播一声刀光音效"的节点,音效 0.3 秒就播完了,
-- 节点时间区间走完时音效早就结束了,OnEnd 里的 StopSound 没必要调
-- 但如果序列被提前打断(比如角色被击飞,整个技能序列被销毁),
-- 此时音效可能还在播,必须补一次 OnEnd 把它停掉
self:OnEnd()
end
-- 最终释放资源
self:OnDestroy()
self.isStarted = false
self.isDestroyed = true
end
----------------------------------------------------------------------
-- 以下 On 系列方法由子类重写,基类提供空实现
----------------------------------------------------------------------
function ActionNode:OnCreate() end -- 初始化(预加载资源等)
function ActionNode:OnPlay(passTime) end -- 进入激活(播动画、创建特效…)
function ActionNode:OnUpdate(passTime) end -- 激活中每帧(更新位置、检查条件…)
function ActionNode:OnEnd() end -- 离开激活(停动画、销毁特效…)
function ActionNode:OnDestroy() end -- 最终销毁(释放资源、清理引用)
和 PlayableBehaviour 相比,有几个明显的区别:
- 没有
PrepareFrame/ProcessFrame的拆分。PlayableBehaviour 先PrepareFrame(前序遍历,父节点先算权重),再ProcessFrame(后序遍历,子节点先处理数据),这是为了支持 Mixer 权重混合。我们不需要混合,一个OnUpdate就够了。 - 没有
OnBehaviourPause。Timeline 里 Clip 离开激活区间时触发OnBehaviourPause,我们直接叫OnEnd,语义更明确——节点结束了,做清理。 passTime参数。OnPlay和OnUpdate都带一个passTime,表示从节点 startTime 开始已经过了多少毫秒。用途是处理”跳入”——如果序列是从中间开始播的(循环播放、断线重连恢复进度),节点的OnPlay被调用时passTime不是 0,特效和动画需要跳到对应的时间点。endNotAction字段。有些节点结束时不需要执行清理逻辑(比如一个”播一次性音效”的节点,音效播完自己就停了,不需要手动 Stop)。配了endNotAction = true后,BehaviourEnd不会调OnEnd,但BehaviourDestroy(序列整体销毁)时还是会调,确保资源不泄漏。
ActionNodeFactory:节点工厂
节点类型有几十种,运行时需要根据配置里的 type 字符串(比如 "PlayAnimNode"、"PlayEffectNode")创建对应的节点实例——说白了就是怎么把一个字符串变成一个具体的类实例。
C# 项目的常见做法是反射(Activator.CreateInstance)或者手动维护一张 Dictionary<string, Type> 注册表。Lua 项目更简单——require 本身就是按字符串加载模块,天然能做动态加载,工厂只需要几行代码:
-- ActionNodeFactory.lua
local ActionNodeFactory = {}
-- 已加载的节点类缓存,key 是类型字符串,value 是 require 回来的类
-- 同一种节点类型只 require 一次,后续走缓存
local NodeClassCache = {}
--- 根据配置里的 type 字符串创建节点实例
--- @param nodeType string 节点类型名,如 "PlayAnimNode"
--- @param index number 节点在序列中的序号
--- @param nodeData table 该节点的配置数据(startTime / endTime / param 等)
--- @param sequence ActionSequence 所属序列实例
function ActionNodeFactory.CreateNode(nodeType, index, nodeData, sequence)
if not NodeClassCache[nodeType] then
-- 首次碰到这个类型,require 对应的 Lua 文件
-- 比如 nodeType = "PlayAnimNode" → require("ActionNode.PlayAnimNode")
-- 返回的就是 PlayAnimNode 这个 class table
NodeClassCache[nodeType] = require("ActionNode." .. nodeType)
end
local NodeClass = NodeClassCache[nodeType]
-- 调用 Class 框架的 New 方法,内部会走到 PlayAnimNode:Ctor(...)
return NodeClass.New(index, nodeData, sequence)
end
整个流程:配置里写 type = "PlayAnimNode" → 工厂拼出路径 "ActionNode.PlayAnimNode" → require 加载这个 Lua 文件拿到类 → 调 New 创建实例。新增一种节点只需要在 ActionNode/ 目录下加一个 Lua 文件,工厂自动就能找到它,不需要在任何地方手动注册。
对比 Timeline 的四件套(TrackAsset + PlayableAsset + PlayableBehaviour + MixerBehaviour),这里一个节点就是一个文件、一次 require。
ActionSequence:序列实例
ActionSequence 对应一次序列播放。构造时读取配置、创建所有节点实例,之后每帧 Update() 推进时间,根据当前播放进度判断每个节点该触发哪个回调。
构造阶段:读配置拿到节点列表,遍历每个 nodeData 调工厂创建对应的 ActionNode 实例,同时取所有节点 endTime 的最大值作为序列总时长 duration。创建完立刻调 BehaviourCreate(),让节点做初始化(比如预加载资源)。
每帧 Update:核心逻辑就一件事——遍历所有节点,拿当前播放时间和每个节点的 [startTime, endTime) 区间做比较,决定触发什么回调。分三种情况:
- 当前时间落在节点区间内 → 如果节点还没激活就先
BehaviourPlay,已经激活了就BehaviourUpdate - 节点已经激活但当前时间已经超出区间 →
BehaviourEnd - 上一帧还没到
startTime、这一帧已经超过endTime(一帧跳过了整个节点) → 仍然触发一次BehaviourPlay,保证”播一声音效”这种瞬时节点不会因为卡顿被跳过
时间走完:根据结束类型分三路处理——Destroy 销毁自己、Loop 重置时间从头播、Wait 停在最后一帧等外部处理。
-- ActionSequence.lua
---@class ActionSequence
local ActionSequence = Class("ActionSequence")
local ActionNodeFactory = require("ActionNodeFactory")
local ActionDataUtil = require("ActionDataUtil")
local ActionEndType = { Destroy = 1, Wait = 2, Loop = 3 }
----------------------------------------------------------------------
-- 构造:读取配置 → 创建节点实例 → 算出序列总时长
-- uid 唯一标识,由 ActionSequencePlayer 分配
-- dataId 表现序列的标识 ID,比如 "Skill_FireBall",传给 GetActionData 加载对应 Lua 配置
-- player 所属的 ActionSequencePlayer 实例
-- endCallback 序列播完后的外部回调(Destroy 模式下触发)
-- modelContainer 播放目标,子类节点通过 self.sequence.modelContainer 拿上下文
----------------------------------------------------------------------
function ActionSequence:Ctor(uid, dataId, player, endCallback, modelContainer)
self.uid = uid
self.dataId = dataId
self.player = player
self.endCallback = endCallback
self.modelContainer = modelContainer
-- 读配置,拿到整个序列的数据(结束类型 + 节点列表)
local actionData = ActionDataUtil.GetActionData(dataId)
self.endType = actionData.actionEndType or ActionEndType.Destroy
self.duration = 0 -- 序列总时长(毫秒),下面遍历时算出
self.playingTime = 0 -- 当前播放进度(毫秒)
self.startPlayTime = Time.curTime -- 记录开始播放的真实时间戳
self.pauseAccumulated = 0 -- 累计暂停了多长时间,算进度时要减掉
self.isPaused = false
-- 遍历配置里的每个节点,调工厂创建实例
self.nodes = {}
for i, nodeData in ipairs(actionData.node) do
local node = ActionNodeFactory.CreateNode(nodeData.type, i, nodeData, self)
self.nodes[i] = node
-- 所有节点里最晚的 endTime 就是序列总时长
self.duration = math.max(self.duration, nodeData.endTime)
-- 创建完立刻触发 OnCreate,让节点做初始化
node:BehaviourCreate()
end
end
----------------------------------------------------------------------
-- 开始播放
-- playingTime 设为 -1 而不是 0,是为了处理 startTime=0 的节点。
--
-- 看 Update 里的情况 3(跳帧兜底):
-- 条件是 lastPlayingTime < nodeStart and playingTime >= nodeStart
--
-- 假设某个节点 startTime=0, endTime=50,但首帧算出来 playingTime=80,
-- 整个区间 [0,50) 被一帧跳过了:
-- 如果初始值是 0 → lastPlayingTime=0, 条件变成 0<0 → false,节点丢了
-- 如果初始值是 -1 → lastPlayingTime=-1, 条件变成 -1<0 → true,节点能被兜住
--
-- 正常不跳帧的情况也没影响:lastPlayingTime=-1, playingTime≈0,
-- 走的是情况 1(0 >= 0 and 0 < 50),一样正常触发 BehaviourPlay
----------------------------------------------------------------------
function ActionSequence:Start()
self.playingTime = -1
self:Update()
end
----------------------------------------------------------------------
-- 核心循环:每帧调用,推进时间并遍历节点触发回调
----------------------------------------------------------------------
function ActionSequence:Update()
if self.playingTime >= self.duration then return end -- 已经播完了
if self.isPaused then return end -- 暂停中
local lastPlayingTime = self.playingTime
-- 用真实时间差算出当前播放进度(减去暂停累计时长)
self.playingTime = (Time.curTime - self.startPlayTime - self.pauseAccumulated)
-- 遍历所有节点,逐个判断当前时间和节点区间的关系
for _, node in ipairs(self.nodes) do
local nodeStart = node.data.startTime
local nodeEnd = node.data.endTime
if self.playingTime >= nodeStart and self.playingTime < nodeEnd then
------- 情况 1:当前时间落在节点的 [startTime, endTime) 区间内 -------
if node.isStarted then
-- 节点已经激活了,正常每帧 Update
node:BehaviourUpdate(self.playingTime - nodeStart)
else
-- 节点刚进入区间,先 Play 激活,再 Update
node:BehaviourPlay(self.playingTime - nodeStart)
node:BehaviourUpdate(self.playingTime - nodeStart)
end
elseif node.isStarted then
------- 情况 2:节点之前是激活的,但当前时间已经超出区间了 -------
node:BehaviourEnd()
elseif lastPlayingTime < nodeStart and self.playingTime >= nodeStart then
------- 情况 3:上一帧还没到 startTime,这一帧直接跳过了整个区间 -------
-- 帧率太低或者卡了一下,deltaTime 特别大,整个节点被跳过了
-- 但仍然要触发一次 Play,否则"播一声音效"这种瞬时节点就丢了
node:BehaviourPlay(self.playingTime - nodeStart)
if self.playingTime >= nodeEnd then
node:BehaviourEnd()
end
end
end
-- 时间走完了,根据结束类型决定下一步
if self.playingTime >= self.duration then
self:OnTimeEnd()
end
end
----------------------------------------------------------------------
-- 序列时间走完时的处理
----------------------------------------------------------------------
function ActionSequence:OnTimeEnd()
if self.endType == ActionEndType.Destroy then
-- Destroy:播完即销毁,通知管理器移除自己,触发外部回调
self.player:RemoveAction(self.uid)
if self.endCallback then self.endCallback() end
elseif self.endType == ActionEndType.Loop then
-- Loop:重置时间从头播
self:RestartPlay()
end
-- Wait:什么都不做,序列停在最后一帧,等外部主动 Remove
end
----------------------------------------------------------------------
-- 循环播放:重置时间,立刻 Update 开始新一轮
-- 注意这里只重置了时间,没有重新创建节点:
-- 上一轮结尾时各节点已经走过 BehaviourEnd(isStarted=false),
-- 时间重置后 Update 里节点重新落入 [startTime, endTime) 区间,
-- 走的是 "未激活 → BehaviourPlay" 的分支,不会再触发 BehaviourCreate。
-- 所以循环时的生命周期是:End → Play → Update → ... → End → Play → ...
-- 而 Create 只在构造阶段执行一次(比如预加载资源),不会重复执行
----------------------------------------------------------------------
function ActionSequence:RestartPlay()
self.startPlayTime = Time.curTime
self.playingTime = 0
self.pauseAccumulated = 0
self:Update()
end
----------------------------------------------------------------------
-- 销毁序列:遍历所有节点触发 BehaviourDestroy 做兜底清理
----------------------------------------------------------------------
function ActionSequence:Dispose()
for _, node in ipairs(self.nodes) do
node:BehaviourDestroy()
end
self.nodes = {}
end
和 Timeline 里 TimelinePlayable.PrepareFrame 用 IntervalTree 搜索激活 Clip 的思路一样,只是我们节点数量不多(一个技能通常不超过 20 个节点),直接线性遍历就够了,没有使用区间树。
ActionSequencePlayer:序列管理器
ActionSequencePlayer 挂在每个需要播放表现的对象上(角色、怪物、NPC 等),负责管理该对象上所有正在运行的序列。如果项目表现层有 Entity 的概念,可以把它理解为 Entity 身上的一个组件。不过 Entity 通常不会直接持有它——中间一般还隔着一个 ModelContainer(模型容器),由 ModelContainer 来管理 GameObject 和挂载的各种表现组件,ActionSequencePlayer 就是其中之一。外部调用 Entity:PlayAction() 时,实际是转发给 ModelContainer,再转发到 ActionSequencePlayer。
除了基本的播放/停止/销毁,它还处理一个关键问题——层级打断。
游戏里经常碰到这种情况:角色正在播待机动作,突然触发了一个攻击技能。攻击的动画、特效、音效等都要同时播出来,但待机动作也不应该被强行打断(比如攻击只管上半身,下半身可能还在走路)。反过来,如果角色正在播攻击 A,又触发了攻击 B,那攻击 A 就该被打断换成 B。
这就是 actionLayer 要解决的问题:同层互斥,不同层共存。
每个序列可以带一个 actionLayer 标记(可以在配置里写死,也可以运行时根据动画资源动态推断)。ActionSequencePlayer 内部用 layerToSequence 映射表记录每层当前占位的序列。新序列进来时,检查同层有没有旧的,有就先干掉旧的,再把新的放上去。不同层的序列互不干扰,可以同时跑。
-- ActionSequencePlayer.lua
---@class ActionSequencePlayer
local ActionSequencePlayer = Class("ActionSequencePlayer")
----------------------------------------------------------------------
-- 构造
-- modelContainer 播放目标对象(角色/怪物/NPC 等),
-- 后续创建 ActionSequence 时会把它传进去,
-- 子节点通过 sequence.modelContainer 拿到播放上下文
----------------------------------------------------------------------
function ActionSequencePlayer:Ctor(modelContainer)
self.modelContainer = modelContainer
self.uidCounter = 0
-- 两张表维护同一批序列,各有侧重:
-- uidToSequence 用 uid 做 key,O(1) 查找/移除
-- runningUidList 有序数组,保证 Update 遍历顺序稳定(先播的先 Update)
self.uidToSequence = {}
self.runningUidList = {}
-- 层级打断的核心映射:actionLayer → 当前占位的 ActionSequence
-- 同一层只保留一个序列,新的进来会把旧的打断
self.layerToSequence = {}
end
----------------------------------------------------------------------
-- 播放一个表现序列
-- 流程:生成 uid → 创建序列实例 → 层级打断检查 → 注册 → 启动
-- 返回 uid,外部可以拿这个 uid 后续主动 RemoveAction / 查询状态
----------------------------------------------------------------------
function ActionSequencePlayer:PlayAction(dataId, endCallback)
local uid = self:GenerateUid()
local sequence = ActionSequence.New(uid, dataId, self, endCallback, self.modelContainer)
-- 重要:先做打断检查再注册,否则新旧序列会同时存在于映射表中
self:TryBreakAction(sequence)
self.uidToSequence[uid] = sequence
table.insert(self.runningUidList, uid)
sequence:Start()
return uid
end
----------------------------------------------------------------------
-- 层级打断
-- 规则很简单:同 layer 只留最新的,旧的直接 Remove
-- actionLayer 为 nil 的序列不参与打断机制,
-- 比如一个纯音效序列不需要占层,多个音效可以叠着播
----------------------------------------------------------------------
function ActionSequencePlayer:TryBreakAction(newSequence)
local layer = newSequence.actionLayer
if layer == nil then return end
-- 该层已有序列在跑 → 先干掉旧的,再让新的占上这个位置
if self.layerToSequence[layer] then
self:RemoveAction(self.layerToSequence[layer].uid)
end
self.layerToSequence[layer] = newSequence
end
----------------------------------------------------------------------
-- 按 dataId 停止所有同名序列
-- 为什么不在遍历里直接 Remove?因为 RemoveAction 会改 uidToSequence,
-- 遍历中改表在 Lua 里是未定义行为,所以先收集 uid 再统一移除
----------------------------------------------------------------------
function ActionSequencePlayer:StopActionById(dataId)
local toRemove = {}
for uid, seq in pairs(self.uidToSequence) do
if seq.dataId == dataId then
table.insert(toRemove, uid)
end
end
for _, uid in ipairs(toRemove) do
self:RemoveAction(uid)
end
end
----------------------------------------------------------------------
-- 移除并销毁指定 uid 的序列
-- 这是所有移除操作的最终出口,TryBreakAction / StopActionById / 外部主动停止
-- 都会走到这里
----------------------------------------------------------------------
function ActionSequencePlayer:RemoveAction(uid)
local sequence = self.uidToSequence[uid]
if not sequence then return end
-- 清理层级映射
-- 防御性检查:只有当 layerToSequence[layer] 还是自己时才清
-- 当前 TryBreakAction 的流程是先 Remove 旧序列再写入新序列,此时检查必然通过
-- 但 RemoveAction 也可能被其他路径调用(如节点回调中重入触发 StopAction),
-- 那时 layerToSequence[layer] 可能已经指向了新序列,不判断 uid 就会误清新序列的层位
local layer = sequence.actionLayer
if layer and self.layerToSequence[layer]
and self.layerToSequence[layer].uid == uid then
self.layerToSequence[layer] = nil
end
-- 销毁序列本身(会触发所有节点的 BehaviourDestroy 做资源清理)
sequence:Dispose()
self.uidToSequence[uid] = nil
-- 从有序列表中移除(线性查找,序列数量通常很少,不是瓶颈)
for i, checkUid in ipairs(self.runningUidList) do
if checkUid == uid then
table.remove(self.runningUidList, i)
break
end
end
end
----------------------------------------------------------------------
-- 每帧驱动所有正在跑的序列
-- 驱动方式有两种常见做法:
-- 方案 A(管理器统一驱动):外部每帧调 ActionSequencePlayer:Update(),
-- 由管理器遍历所有序列逐个 Update,控制权集中,方便统一暂停/变速
-- 方案 B(序列自驱动):每个序列 Start 时自己注册一个定时器回调,
-- 不需要管理器操心遍历,序列之间完全独立,但暂停/变速要各自处理
-- 这里用方案 A 示意,实际项目里方案 B 也很常见
----------------------------------------------------------------------
function ActionSequencePlayer:Update()
for _, uid in ipairs(self.runningUidList) do
self.uidToSequence[uid]:Update()
end
end
----------------------------------------------------------------------
-- 生成唯一 uid,自增计数 + 字符串前缀,简单够用
----------------------------------------------------------------------
function ActionSequencePlayer:GenerateUid()
self.uidCounter = self.uidCounter + 1
return "ActionSeq_" .. self.uidCounter
end
----------------------------------------------------------------------
-- 对象被销毁时调用(比如角色被回收、场景卸载等)
-- 遍历所有还在跑的序列,逐个 Dispose 做兜底清理,
-- 确保不会有特效/音效残留在场景里
----------------------------------------------------------------------
function ActionSequencePlayer:Dispose()
for _, uid in ipairs(self.runningUidList) do
self.uidToSequence[uid]:Dispose()
end
self.uidToSequence = {}
self.runningUidList = {}
self.layerToSequence = {}
end
对比 Timeline 的 PlayableDirector:Director 管的是单条 Timeline 的播放/暂停/停止,一个 Director 绑定一条 Timeline。而 ActionSequencePlayer 管的是同一个对象上多条并发序列,加上层级打断。如果用 Timeline 做类似的事,得给一个对象挂多个 PlayableDirector,打断逻辑还得自己再写一套(第 5 篇的过场编辑器里确实就是这么干的)。
结束类型
actionEndType 三种模式的行为:
- **Destroy (1)**:序列播完自动销毁,触发结束回调。最常用,比如一个攻击动作播完就完了。
- **Wait (2)**:序列播到最后一帧后停住,不销毁也不回调。用于等待外部条件,比如角色采集动作播完后要等服务器确认才能继续。
- **Loop (3)**:序列播完后从头开始,可以配循环次数。比如待机表现、钓鱼的甩杆循环。
这三种在 Timeline 里也有对应的 WrapMode(None / Hold / Loop),但 Timeline 的 Loop 是整条时间线循环,包括所有轨道上所有 Clip 重新走一遍 OnGraphStart。而我们的 Loop 更灵活——循环时只重新触发节点的 OnPlay,不再调 OnCreate,所以一些只需要初始化一次的逻辑(比如预加载资源)不会重复执行。
6.5 编辑器(C# / UIToolkit)
UIToolkit、IMGUI 与 Odin
Unity 编辑器自定义窗口常用的有两套底层方案:IMGUI 和 UIToolkit。做 Timeline 类编辑器时可以混用;若数据里有 Dictionary、多态等,再配合 Odin 做序列化和 Inspector。
IMGUI
每帧在OnGUI()里用EditorGUILayout、GUILayout、Handles绘制控件,不保留节点树。与自带 Inspector 同套 API,适合快速搭小工具,但布局和状态需自行维护,拖拽、多选、嵌套滚动多了时代码难以维护。因此只用于必须即时绘制的部分:如时间刻度尺、游标线,需根据缩放和偏移动态画线或数字,用Handles.DrawLine最直接,UIToolkit 无法胜任。在 UXML 中放置一个IMGUIContainer,指定onGUIHandler,在回调内写 IMGUI 即可。UIToolkit
保留模式:结构在.uxml,样式在.uss,C# 负责加载、查询节点、绑定事件、更新数据。- 界面编辑:通过菜单创建 .uxml(Assets → Create → UI Toolkit),双击以 UI Builder 打开,左侧控件库、中间画布、右侧属性面板,编排层级与样式后保存。
- 代码流程:在
CreateGUI()中LoadAssetAtPath<VisualTreeAsset>加载 uxml,Instantiate()得到整棵 VisualElement 树并挂到rootVisualElement,再用root.Q<Button>("name")按 name 获取控件,通过RegisterCallback或clicked +=绑定事件。 - 时间轴:轨道与片段均为 VisualElement,拖拽与边缘拉伸通过 MouseDown/Move/Up 事件处理;缩放时修改「单位时间对应像素」并刷新所有片段的位置与宽高。
混用
主框架、表单、轨道列表、可拖拽片段用 UIToolkit;刻度尺、游标等需动态绘制的部分用IMGUIContainer嵌入 IMGUI。Odin(按需)
- 序列化:需序列化 Dictionary、多态等 Unity 默认不支持的类型时,使用
[OdinSerialize],窗口继承OdinEditorWindow,数据类字段标记[NonSerialized, OdinSerialize]。 - Inspector:继承
OdinEditor可使用 Odin 的绘制能力;若字段由运行时元数据动态生成,可不用 PropertyTree,自行用 UIToolkit 控件组装参数面板。
- 序列化:需序列化 Dictionary、多态等 Unity 默认不支持的类型时,使用
界面布局
界面按「左侧轨道列表 + 右侧时间轴」排布,和 Unity 自带的 Timeline 窗口类似,上面两栏放配置和操作按钮:
┌─────────────────────────────────────────────────────────┐
│ TopMenu: 配置名 | 备注 | 结束类型 | 配置选择 │
├─────────────────────────────────────────────────────────┤
│ AddTrackMenu: 节点类型选择 | 新增 | 保存 | 预览 │
├──────────┬──────────────────────────────────────────────┤
│ Left │ Right │
│ ◀ ▶ ▶ │ 0 500 1000 1500 2000 │
│ -1/1300 │ ┃ ┃ ┃ ┃ ┃ │
│──────────│───────────────────────────────────────────────│
│ Del 0 │ ██ PlayAnimNode 播放动画 ████████ │
│ Del 1 │ ████ PlayEffectNode 播放特效 ████ │
│ Del 2 │ ██████ PlaySoundNode 播放音效 ██ │
│ │ │
└──────────┴──────────────────────────────────────────────┘
整体效果如下(左为全窗,右为时间轴区域特写;刻度尺为 IMGUI,轨道与片段为 UIToolkit):

各区域职责:
- TopMenu:当前配置名、备注、结束类型(Destroy / Wait / Loop)、配置选择与加载
- AddTrackMenu:节点类型选择、新增节点、保存、预览播放等操作
- Left:播控(上一帧 / 播放 / 下一帧)、当前帧与总帧数、轨道列表(每行带 Del 删除)
- Right:时间刻度尺、轨道内容区(片段可拖拽、边缘拉伸改时长;Ctrl + 滚轮缩放;一轨一道,一个 node 一行)
总体搭建
一般是先搭好 UXML,再写 C#。在 UI Builder 里把主窗口的四个区域(TopMenu、AddTrackMenu、Left、Right)和里面要绑逻辑的控件都摆好,给每个控件设好 name,保存成例如 ActionEditor.uxml。

主窗口 UXML 的简化结构如下(省略 xmlns、部分 style 和子节点,只保留层级与 name),可与上面四块区域一一对照:
<ui:UXML ...> <!-- 此处省略 xmlns、engine、editor 等命名空间与 schema 属性 -->
<!-- 区域一:TopMenu — 配置名、备注、结束类型、配置选择与加载 -->
<ui:VisualElement name="TopMenu" style="..."> <!-- 此处省略 height、flex-basis、border、margin 等 style -->
<ui:TextField name="LuaConfigNameText" /> <ui:Button name="ClearBtn" /> <ui:TextField name="RemarkText" />
<ui:DropdownField name="EndTypeDropdown" /> <ui:Button name="SelectLuaConfigFileButton" /> <ui:DropdownField name="LuaConfigDropdown" />
</ui:VisualElement>
<!-- 区域二:AddTrackMenu — 节点类型选择、新增/保存/预览、测试模型 -->
<ui:VisualElement name="AddTrackMenu" style="...">
<ui:VisualElement name="ActionNodeSelector">...</ui:VisualElement> <!-- 此处省略内部 Label、Button 等子节点 -->
<ui:Button name="AddActionNodeBtn" /> <ui:Button name="SaveBtn" /> <ui:Button name="CreateTestModelBtn" />
<ui:TextField name="CreateModelIDText" /> <ui:Button name="SelectModelIdBtn" />
</ui:VisualElement>
<!-- 区域三:Content — 左侧轨道列表 + 右侧时间轴,flex-direction: row -->
<ui:VisualElement name="Content" style="flex-direction: row; ...">
<!-- Left:固定宽 200px,上为播控与帧数,下为轨道列表 -->
<ui:VisualElement name="Left" style="width: 200px; ..."> <!-- 此处省略 border、margin 等 -->
<ui:VisualElement name="Controller" style="..."> <!-- 此处省略 style;内部为上一帧/播放/下一帧、当前帧/总帧数 -->
...
</ui:VisualElement>
<ui:ScrollView name="TrackMenuScrollView" ...> <!-- 此处省略 horizontal/vertical-scroller-visibility 等 -->
<ui:VisualElement name="TrackMenuList" style="..."/> <!-- 每条轨道左侧一行(Del+标题)动态添加到此;此处省略 style -->
</ui:ScrollView>
</ui:VisualElement>
<!-- Right:flex-grow 占满剩余,上为刻度尺、中为轨道内容区、游标线 -->
<ui:VisualElement name="Right" style="flex-grow: 1; ...">
<ui:IMGUIContainer name="TimeShaft" style="..."/> <!-- 时间刻度尺,onGUIHandler 里 Handles 画线;此处省略 style -->
<ui:ScrollView name="MainContentView" ...>
<ui:VisualElement name="ContentListView" style="..."/> <!-- 每条轨道右侧条、片段块动态添加到此;此处省略 style -->
</ui:ScrollView>
<ui:IMGUIContainer name="SelectLine" style="..."/> <!-- 当前帧游标线;此处省略 style -->
</ui:VisualElement>
</ui:VisualElement>
</ui:UXML>
C# 里主要是加载这份 UXML、按 name 用 Q<> 查出控件、在各自的 Init 里绑事件和填数据。布局改动只动 UXML,逻辑集中在 C#,对应关系靠 name 串起来。
窗口类继承 OdinEditorWindow(或 EditorWindow),持有根节点引用、当前序列数据、编辑器配置,以及四块区域的控件引用(在各 Init 里赋值)。类结构伪代码如下:
// ActionSequenceEditorWindow.cs — 表现序列编辑器主窗口,加载 UXML、按区域 Init 并绑定事件
public class ActionSequenceEditorWindow : OdinEditorWindow
{
/// <summary>
/// UI 根节点,CreateGUI 里赋值为 rootVisualElement,后续 Q 控件都基于它。
/// </summary>
private VisualElement _rootVisualElement;
/// <summary>
/// 当前编辑的序列配置(结束类型、节点列表等),保存时序列化成 Lua。
/// </summary>
public ActionSequenceData sequenceData;
/// <summary>
/// 编辑器配置,如每帧像素宽、缩放等级,用于时间轴刻度和片段宽度计算。
/// </summary>
public ActionEditorConfig editorConfig;
#region TopMenu 控件引用
/// <summary>
/// 当前编辑的配置文件名输入框,对应 UXML name="LuaConfigNameText"。
/// </summary>
private TextField _configNameText;
/// <summary>
/// 清空当前配置并重置为新建状态的按钮。
/// </summary>
private Button _clearBtn;
/// <summary>
/// 配置备注信息输入框。
/// </summary>
private TextField _remarkText;
/// <summary>
/// 结束类型下拉框(Destroy / Wait / Loop)。
/// </summary>
private DropdownField _endTypeDropdown;
/// <summary>
/// 打开配置选择窗口的按钮,选中后加载对应 Lua 配置。
/// </summary>
private Button _selectConfigFileBtn;
/// <summary>
/// 已存在配置列表下拉框,切换时加载并刷新轨道。
/// </summary>
private DropdownField _configDropdown;
#endregion
#region AddTrackMenu 控件引用
/// <summary>
/// 节点类型选择按钮,点击打开类型选择弹窗,选中后作为「新增」时的节点类型。
/// </summary>
private Button _nodeTypeSelectBtn;
/// <summary>
/// 新增一条轨道的按钮。
/// </summary>
private Button _addNodeBtn;
/// <summary>
/// 保存当前配置为 Lua 文件的按钮。
/// </summary>
private Button _saveBtn;
/// <summary>
/// 模型 ID 输入框,创建测试模型时读取此值。
/// </summary>
private TextField _createModelIdText;
#endregion
#region ContentLeft:播控与帧数
/// <summary>
/// 当前帧数输入框(IntegerField),播放时实时更新,
/// 也作为 SelectLine 指示线的数据来源。
/// </summary>
private IntegerField _currentFrameText;
/// <summary>
/// 总帧数输入框(IntegerField),修改会更新序列时长与内容区宽度。
/// </summary>
private IntegerField _frameCountText;
/// <summary>
/// 是否正在预览播放。
/// </summary>
private bool _isPlaying;
/// <summary>
/// 播放起始时刻(EditorApplication.timeSinceStartup,秒)。
/// </summary>
private double _playStartEditorTime;
#endregion
#region ContentRight:刻度尺与游标
/// <summary>
/// 时间刻度尺 IMGUIContainer,onGUIHandler 内用 Handles 画刻度和时间数字。
/// </summary>
private IMGUIContainer TimeShaft;
/// <summary>
/// 当前帧指示线 IMGUIContainer,覆盖在右侧面板上方,
/// 用 Handles.DrawLine 画一条白色竖线标记当前帧位置。
/// </summary>
private IMGUIContainer SelectLine;
#endregion
#region ContentMiddle:内容区容器与轨道视图
/// <summary>
/// 左侧轨道列表的父节点(UXML name="TrackMenuList"),每条轨道的「Del + 标题」行动态加到此容器下。
/// </summary>
private VisualElement _trackMenuList;
/// <summary>
/// 右侧轨道内容区的父节点(UXML name="ContentListView"),每条轨道的条和片段动态加到此容器下。
/// </summary>
private VisualElement _contentListView;
/// <summary>
/// 右侧可横向/纵向滚动的 ScrollView(UXML name="MainContentView"),内部是 ContentListView,InitContentRight 里也会 Q 到用于绑滚轮缩放。
/// </summary>
private ScrollView _mainContentView;
/// <summary>
/// 左侧轨道列表的 ScrollView(UXML name="TrackMenuScrollView"),与 MainContentView 垂直滚动同步,保证左右对齐。
/// </summary>
private ScrollView _trackMenuScrollView;
/// <summary>
/// 轨道 ID → 轨道视图,加轨道时新增、删轨道时移除,InitContent 里根据序列数据重建。
/// </summary>
private Dictionary<int, TrackViewBase> _trackViewDict;
#endregion
}
CreateGUI() 在窗口打开时被 Unity 调用。加载 UXML 挂到根节点、初始化数据和配置、按区域调 Init,最后用 FileSystemWatcher 监听节点 Lua 目录——文件一改就清参数缓存,下次取参数时自动重新解析。
// ActionSequenceEditorWindow.cs
/// <summary>
/// 窗口打开时调用:加载 UXML、初始化数据、按区域 Init、监听节点 Lua 目录。
/// </summary>
public void CreateGUI()
{
// 根节点与布局
_rootVisualElement = rootVisualElement;
VisualTreeAsset tree = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>("Assets/Editor/.../Editor.uxml");
_rootVisualElement.Add(tree.Instantiate());
// 数据与配置
sequenceData = new ActionSequenceData();
editorConfig = new ActionEditorConfig();
// 按区域初始化控件并绑定事件
InitTopMenu();
InitAddTrackMenu();
InitContentLeft();
InitContentRight();
InitContent();
// 监听节点 Lua 目录:.lua 文件变更时清空参数元数据缓存,下次取参数时重新解析
string nodeDir = Path.GetFullPath(Path.Combine(Application.dataPath, LUA_NODE_REL_DIR));
FileSystemWatcher watcher = new FileSystemWatcher
{
Path = nodeDir,
Filter = "*.lua",
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName
};
watcher.Changed += (s, e) => NodeParamMetaCache.ClearCache();
watcher.EnableRaisingEvents = true;
}
参数元数据缓存与文件监听
上面 CreateGUI 末尾用 FileSystemWatcher 监听了节点 Lua 目录,回调里调 NodeParamMetaCache.ClearCache()——展开说下这个缓存机制。
6.3 节提到编辑器通过解析节点 Lua 文件头部的注释块拿到参数元数据。解析结果封装为两个类:NodeParamMeta(节点类型名、中文名、参数字段列表)和 NodeParamFieldMeta(字段名、类型、中文描述)。同一种节点只需解析一次,所以按类型做静态缓存;而 Lua 文件改动后,FileSystemWatcher 触发 ClearCache(),下次取参数时自动重新解析。
// NodeParamMeta.cs
/// <summary>
/// 节点参数元数据:节点类型名、中文名、参数字段列表。
/// 一个 NodeParamMeta 对应一种节点类型(如 PlayAnimNode)。
/// </summary>
public class NodeParamMeta
{
/// <summary>节点类型名,如 "PlayAnimNode"。</summary>
public string NodeType;
/// <summary>中文名,显示在轨道和 Inspector 上,如 "播放动画"。</summary>
public string ChineseName;
/// <summary>该节点所有可配参数的字段元数据列表。</summary>
public List<NodeParamFieldMeta> NodeParamFieldMetaList;
}
// NodeParamFieldMeta.cs
/// <summary>
/// 单个参数字段元数据:字段名、类型、中文描述。
/// 编辑器根据这个列表动态生成 Inspector 里对应类型的输入控件。
/// </summary>
public class NodeParamFieldMeta
{
/// <summary>字段名,如 "animName"。</summary>
public string name;
/// <summary>类型字符串,如 "string"、"int"、"bool"、"vector3"。</summary>
public string type;
/// <summary>中文描述,作为 Inspector 控件的 label 显示,如 "动画名称"。</summary>
public string description;
}
解析结果按节点类型缓存在静态类 NodeParamMetaCache 里,外部统一走 GetNodeParamMeta 取元数据:
// NodeParamMetaCache.cs
/// <summary>
/// 节点参数元数据缓存,按节点类型做 key,同一种节点只解析一次 Lua。
/// CreateGUI 里的 FileSystemWatcher 在 .lua 文件变更时调 ClearCache(),
/// 下次 GetNodeParamMeta 时重新解析,拿到最新参数定义。
/// </summary>
public static class NodeParamMetaCache
{
private static readonly Dictionary<string, NodeParamMeta> _cache
= new Dictionary<string, NodeParamMeta>();
/// <summary>
/// 按节点类型取元数据,命中缓存直接返回,否则调 LuaParamParser 解析后写入缓存。
/// </summary>
public static NodeParamMeta GetNodeParamMeta(string nodeType, string luaFilePath)
{
if (_cache.TryGetValue(nodeType, out NodeParamMeta cached))
return cached;
NodeParamMeta meta = LuaParamParser.ParseLuaFile(luaFilePath);
meta.NodeType = nodeType;
_cache[nodeType] = meta;
return meta;
}
/// <summary>
/// 清空全部缓存,由 FileSystemWatcher.Changed 回调触发。
/// </summary>
public static void ClearCache() => _cache.Clear();
}
顶部菜单 TopMenu

InitTopMenu() 把 TopMenu 区域的控件查出来并绑事件,Q<> 按 UXML 里的 name 查控件,查到后绑回调:
// ActionSequenceEditorWindow.cs
/// <summary>
/// 查询 TopMenu 区域控件并绑定事件。
/// </summary>
private void InitTopMenu()
{
// 配置文件名输入框:新建时可编辑(自动加前缀),加载已有配置时锁定为文件名
_configNameText = _rootVisualElement.Q<TextField>("LuaConfigNameText");
_configNameText.RegisterValueChangedCallback(OnConfigNameChanged);
// 清空按钮:销毁所有轨道、清空 Inspector、重置 sequenceData 为新建状态
_clearBtn = _rootVisualElement.Q<Button>("ClearBtn");
_clearBtn.clicked += OnClickClear;
// 备注:值变化时直接写入 sequenceData.RemarkText
_remarkText = _rootVisualElement.Q<TextField>("RemarkText");
_remarkText.RegisterValueChangedCallback(OnRemarkChanged);
// 结束类型下拉框(Destroy / Wait / Loop):写入 sequenceData.EndType,
// 选 Loop 时额外显示循环次数输入框
_endTypeDropdown = _rootVisualElement.Q<DropdownField>("EndTypeDropdown");
_endTypeDropdown.choices = endTypeChoices;
_endTypeDropdown.value = endTypeChoices[0];
_endTypeDropdown.RegisterCallback<ChangeEvent<string>>(OnEndTypeChanged);
// 配置选择按钮:打开 Picker 弹窗(带搜索、按名称/时间排序),选中后触发下拉框联动
_selectConfigFileBtn = _rootVisualElement.Q<Button>("SelectLuaConfigFileButton");
_selectConfigFileBtn.clicked += OnClickSelectFile;
// 配置下拉框:切换时解析对应 Lua 文件 → 填充 sequenceData → ResetTrack() 重建所有轨道
_configDropdown = _rootVisualElement.Q<DropdownField>("LuaConfigDropdown");
_configDropdown.choices = GetConfigDropdownChoices();
_configDropdown.RegisterCallback<ChangeEvent<string>>(OnConfigDropdownChanged);
}
比较关键的两个回调——清空和切换配置:
// ActionSequenceEditorWindow.cs
/// <summary>
/// 清空按钮:销毁所有轨道和 Inspector 内容,下拉框切回 "New",
/// 重新生成空的 sequenceData,结束类型重置为 Destroy。
/// </summary>
private void OnClickClear()
{
_configNameText.value = "";
DestroyTracks();
Inspector.ClearData();
// "New" 会让 LoadConfig 走新建分支,new 一个空的 sequenceData
_configDropdown.value = NEW_CHOICE;
sequenceData = LoadConfig(_configDropdown.value);
_endTypeDropdown.value = "Destroy";
sequenceData.EndType = "Destroy";
}
/// <summary>
/// 配置下拉框切换:按文件名读 Lua、解析出 actionEndType 和 node 列表,
/// 填入 sequenceData,销毁旧轨道后重建。
/// </summary>
private void OnConfigDropdownChanged(ChangeEvent<string> evt)
{
_configNameText.value = evt.newValue;
// 读 Lua → 逐行解析 → 填充 sequenceData
sequenceData = LoadConfig(evt.newValue);
CurrentFrameCount = sequenceData.FrameCount;
// 销毁旧轨道,按新的 TrackDataDict 重建
ResetTrack();
RefreshPanelInfo();
}
编辑器里 Lua 配置选择、节点类型选择、模型 ID 选择这三处都做了独立的 Picker 弹窗。实现思路一样:继承 EditorWindow,CreateGUI() 里放一个搜索框 TextField + 一个 ScrollView,ScrollView 内用 flexDirection: Row + flexWrap: Wrap 的 VisualElement 容器来排列按钮形成网格。搜索框的 RegisterValueChangedCallback 里按关键字过滤选项并刷新网格,点击按钮时触发外部传入的回调并 Close() 关闭窗口。Lua 配置 Picker 额外支持按名称 A→Z 或按修改时间排序,模型 ID Picker 额外做了实体分类 Tab(玩家、AI、NPC、家具等):

操作菜单 AddTrackMenu

InitAddTrackMenu() 处理新增轨道相关的控件:
// ActionSequenceEditorWindow.cs
/// <summary>
/// 查询 AddTrackMenu 区域控件并绑定事件。
/// </summary>
private void InitAddTrackMenu()
{
// 节点类型选择按钮:弹出 Picker 窗口,按钮文字显示当前选中的类型名 + 中文名
_nodeTypeSelectBtn = _rootVisualElement.Q<Button>("ActionNodeSelectBtn");
_nodeTypeSelectBtn.clicked += OnClickSelectNodeType;
// 新增轨道:按当前选中的节点类型创建一条新轨道
_addNodeBtn = _rootVisualElement.Q<Button>("AddActionNodeBtn");
_addNodeBtn.clicked += OnClickAddNode;
// 保存:把 sequenceData 序列化成 Lua 写到配置目录
_saveBtn = _rootVisualElement.Q<Button>("SaveBtn");
_saveBtn.clicked += OnClickSave;
// 创建测试模型:编辑器未运行时先打开测试场景并 Play,
// 运行中时调 GM 指令在场景里生成指定 ModelId 的表现层模型
Button createTestModelBtn = _rootVisualElement.Q<Button>("CreateTestModelBtn");
createTestModelBtn.clicked += OnClickCreateTestModel;
// 模型 ID 输入框 + Picker 按钮
_createModelIdText = _rootVisualElement.Q<TextField>("CreateModelIDText");
Button selectModelIdBtn = _rootVisualElement.Q<Button>("SelectModelIdBtn");
selectModelIdBtn.clicked += OnClickSelectModelId;
}
节点类型 Picker 列出所有节点 Lua 文件(解析出类型名 + 中文名),模型 ID Picker 按实体分类分 Tab 显示:


几个关键回调:
// ActionSequenceEditorWindow.cs
/// <summary>
/// 新增轨道:在 sequenceData 里分配 trackId 并创建 trackData,
/// 按选中的节点类型初始化参数元数据,然后创建轨道视图。
/// </summary>
private void OnClickAddNode()
{
if (sequenceData == null) return;
// 数据层:分配 trackId、创建 trackData,设节点类型
ActionTrackData trackData = sequenceData.AddTrackData(selectedNodeType);
trackData.NodeType = selectedNodeType;
// 找到节点 Lua 文件,解析注释拿参数元数据
string luaPath = GetLuaFilePathByNodeType(selectedNodeType);
trackData.NodeData.InitNodeParamMeta(luaPath);
// 视图层:创建轨道(左侧标题行 + 右侧内容条 + 片段块)
ActionTrackView trackView = new ActionTrackView();
trackView.Init(_trackMenuList, _contentListView, editorConfig.FrameUnitWidth,
selectedNodeType, trackData);
_trackViewDict.Add(trackData.TrackId, trackView);
}
/// <summary>
/// 保存:把 sequenceData 序列化成 Lua table 字符串,写到配置目录。
/// 新建的配置保存后刷新下拉框列表。
/// </summary>
private void OnClickSave()
{
if (string.IsNullOrEmpty(configName)) return;
string luaStr = SerializeToLua(sequenceData);
string path = Path.Combine(configDir, $"{configName}.lua");
File.WriteAllText(path, luaStr);
if (isNewConfig)
{
_configDropdown.choices = GetConfigDropdownChoices();
_configDropdown.value = configName;
}
}
数据加载与保存
加载已有配置(Lua → C# 数据)
前面 TopMenu 里 OnConfigDropdownChanged 调了 LoadConfig(),把磁盘上的 Lua 配置文件解析成编辑器里的 sequenceData。
Lua 配置文件就是一段固定格式的文本(return { actionEndType = ..., node = { ... } }),加载过程就是纯字符串解析——File.ReadAllText 读进来,逐段提取字段值。没有用第三方 Lua 解析器,格式是自己定义的,手写解析足够了:
// ActionSequenceEditorWindow.cs
/// <summary>
/// 读取 Lua 配置文件,解析成编辑器数据。
/// 流程:读文件 → 提取 actionEndType → 逐个解析 node → 算总时长。
/// </summary>
private ActionSequenceData LoadConfig(string fileName)
{
ActionSequenceData data = new ActionSequenceData();
if (fileName == NEW_CHOICE) return data;
string path = Path.Combine(configDir, $"{fileName}.lua");
if (!File.Exists(path)) return data;
string luaContent = File.ReadAllText(path);
// 1. 提取 actionEndType:找 "actionEndType = " 后面的数字,1→Destroy 2→Wait 3→Loop
ParseActionEndType(luaContent, data);
// 2. 逐个解析 node:找 "node = {" 后面的每组 { ... },
// 从中提取 startTime、endTime、type、endNotAction、param
ParseNodes(luaContent, data);
// 3. 总时长 = 所有节点里最大的 endTime
if (data.TrackDataDict.Count > 0)
{
data.FrameCount = data.TrackDataDict.Values.Max(t => t.NodeData.EndTime);
}
return data;
}
ParseNodes 内部做的事:先在文本里定位到 node = {,然后用大括号匹配逐个找出 { ... } 节点块。对每个块调 ParseSingleNode,提取 startTime、endTime、type、endNotAction 四个公共字段。接着找到 param = { ... } 子块,按 key = value 逐行解析参数——字符串去引号、{x,y,z} 转 Vector3、bool 做类型转换等——写入 ActionNodeData.Name2ValParamsDict。
解析完成后 sequenceData.TrackDataDict 里就有了所有轨道和节点数据,接着调 ResetTrack() 重建轨道视图,走的是同一套 ActionTrackView.Init() 链路。
从编辑到保存:数据流转
用户在 Inspector 里改了参数值,Save 的时候是怎么读到最新数据的?
关键在 ActionNodeData。回头看前面 OnClickAddNode() 里这行:
ActionTrackData trackData = sequenceData.AddTrackData(selectedNodeType);
AddTrackData 内部分配 trackId,然后 new 出 ActionTrackData 并存进字典:
// ActionSequenceData.cs
/// <summary>
/// 新增一条轨道数据,分配 trackId,存进字典并返回。
/// </summary>
public ActionTrackData AddTrackData(string nodeType)
{
int trackId = GetReadyAddTrackId();
ActionTrackData trackData = new ActionTrackData(trackId, nodeType);
TrackDataDict.Add(trackData.TrackId, trackData);
return trackData;
}
而 ActionTrackData 的构造函数里会 new 出 ActionNodeData:
// ActionTrackData.cs
public class ActionTrackData
{
/// <summary>
/// 轨道上的节点数据(一个轨道对应一个节点)。
/// </summary>
public ActionNodeData NodeData;
public ActionTrackData(int trackId, string nodeType)
{
TrackId = trackId;
// 构造时就 new 出 ActionNodeData,默认 startTime=0, endTime=1000
NodeData = new ActionNodeData(trackId, 0, 1000, nodeType);
}
}
之后创建轨道视图时,trackData 被传进 ActionTrackView.Init(),再传给 TrackItem,TrackItem 持有的 nodeData 就是 trackData.NodeData 的引用。所以从头到尾只有一个 ActionNodeData 实例,三方共享同一个对象:
sequenceData.TrackDataDict[trackId].NodeData ──┐
├──→ 同一个 ActionNodeData 实例
TrackItem.nodeData ─────────────────────────────┘
↑ Inspector 通过 trackItem.nodeData 读写
↑ Save 通过 sequenceData 遍历 NodeData 读取
Inspector 改的和 Save 读的是同一份数据,不需要额外同步。节点的参数值存在一个 Dictionary<string, object> 里:
// ActionNodeData.cs — 单个节点的数据模型
[Serializable]
public class ActionNodeData
{
/// <summary>
/// 开始时间。
/// </summary>
public int StartTime;
/// <summary>
/// 结束时间。
/// </summary>
public int EndTime;
/// <summary>
/// 结束不执行动作。
/// </summary>
public bool endNotAction = false;
/// <summary>
/// 节点类型(如 "PlayAnimNode")。
/// </summary>
public string NodeType;
/// <summary>
/// 参数字典:字段名 → 值。
/// Inspector 的 ValueChangedCallback 写入这里,Save 时从这里读取。
/// 这就是编辑和保存之间的数据桥梁。
/// </summary>
public Dictionary<string, object> Name2ValParamsDict = new();
/// <summary>
/// 参数元数据,描述该节点类型有哪些参数字段、各自什么类型。
/// </summary>
public NodeParamMeta NodeParamMeta;
/// <summary>
/// 读参数:从字典里取值。
/// </summary>
public object GetParamValue(string paramName)
{
return Name2ValParamsDict.GetValueOrDefault(paramName);
}
/// <summary>
/// 写参数:Inspector 里改值时调这个,直接写入字典。
/// </summary>
public void SetParamValue(string paramName, object value)
{
Name2ValParamsDict[paramName] = value;
}
}
举个具体例子。假设有一个 PlayAnimNode(播放动画节点),当前字典里的数据是:
Name2ValParamsDict = {
["animName"] = "idle",
["layerId"] = 0,
["speed"] = 1.0f
}
用户在 Inspector 里把 animName 从 idle 改成 attack,把 speed 从 1.0 改成 2.5:
1. 用户修改 animName 输入框:idle → attack
↓ UIToolkit TextField 的 RegisterValueChangedCallback 触发
nodeData.SetParamValue("animName", "attack")
↓ 字典就地更新
Name2ValParamsDict["animName"] = "attack" // 原来是 "idle",现在变成 "attack"
2. 用户修改 speed 输入框:1.0 → 2.5
↓ UIToolkit FloatField 的 RegisterValueChangedCallback 触发
nodeData.SetParamValue("speed", 2.5f)
↓ 字典就地更新
Name2ValParamsDict["speed"] = 2.5f // 原来是 1.0f,现在变成 2.5f
(此时字典已经是最新状态,不需要额外同步)
3. 用户点击 Save 按钮
↓ OnClickSave() → SerializeToLua(sequenceData)
↓ 遍历到这条轨道的 nodeData,逐个字段 GetParamValue:
GetParamValue("animName") → 从字典读出 "attack" → FormatParamValue → 'attack'
GetParamValue("layerId") → 从字典读出 0 → FormatParamValue → 0
GetParamValue("speed") → 从字典读出 2.5f → FormatParamValue → 2.5
↓ 拼成 Lua 字符串写文件
最终输出到 Lua 文件里就是:
{
startTime = 0,
endTime = 1000,
type = 'PlayAnimNode',
endNotAction = false,
param = {
animName = 'attack', -- 用户改过的值
layerId = 0,
speed = 2.5, -- 用户改过的值
}
},
不需要”收集改动”——Inspector 每次修改都直接写到 nodeData 的字典里,Save 的时候原样遍历读出来就行。StartTime、EndTime、endNotAction 几个公共字段也是同理,Inspector 回调里直接改 nodeData.StartTime = newValue,Save 时读 node.StartTime。
序列化为 Lua
SerializeToLua 用 StringBuilder 拼 Lua table 字符串。输出格式:
-- 输出示例
return {
actionEndType = 1,
node = {
{
startTime = 0,
endTime = 1000,
type = 'PlayAnimNode',
endNotAction = false,
param = {
animName = 'attack',
layerId = 0,
}
},
-- ... 其他节点
},
}
实现伪代码:
// ActionSequenceEditorWindow.cs
/// <summary>
/// 遍历 sequenceData 的所有轨道数据,拼成 Lua table 字符串。
/// 每个 node 的 param 按 NodeParamMeta 字段顺序输出,保持 Lua 文件字段顺序稳定。
/// </summary>
private string SerializeToLua(ActionSequenceData data)
{
StringBuilder sb = new StringBuilder();
sb.AppendLine("--Auto Create Dont Change By YourSelf!" + data.RemarkText);
sb.AppendLine("return {");
sb.AppendLine($"\tactionEndType = {FormatEndType(data.EndType)},");
sb.AppendLine("\tnode = {");
foreach (KeyValuePair<int, ActionTrackData> kv in data.TrackDataDict)
{
// 从 TrackData 拿到节点数据——就是 Inspector 一直在读写的那个对象
ActionNodeData node = kv.Value.NodeData;
sb.AppendLine("\t\t{");
// 公共字段直接读
sb.AppendLine($"\t\t\tstartTime = {node.StartTime},");
sb.AppendLine($"\t\t\tendTime = {node.EndTime},");
sb.AppendLine($"\t\t\ttype = '{node.NodeType}',");
sb.AppendLine($"\t\t\tendNotAction = {(node.endNotAction ? "true" : "false")},");
// 参数字段:按元数据的字段列表顺序遍历,从 Name2ValParamsDict 逐个取值
sb.AppendLine("\t\t\tparam = {");
if (node.NodeParamMeta != null)
{
foreach (NodeParamFieldMeta field in node.NodeParamMeta.NodeParamFieldMetaList)
{
object value = node.GetParamValue(field.name);
if (value == null) continue;
// 按类型格式化为 Lua 语法
string formatted = FormatParamValue(value, field.type);
sb.AppendLine($"\t\t\t\t{field.name} = {formatted},");
}
}
sb.AppendLine("\t\t\t}");
sb.AppendLine("\t\t},");
}
sb.AppendLine("\t},");
sb.AppendLine("}");
return sb.ToString();
}
/// <summary>
/// 把 C# 对象按 Lua 语法格式化:
/// string → 'xxx'(加单引号)
/// bool → true / false(小写)
/// Vector3 → {x,y,z}(Lua table)
/// Vector4 → {x,y,z,w}
/// 数值 → 原样输出
/// </summary>
private string FormatParamValue(object value, string type)
{
if (value is string str) return $"'{str}'";
if (value is bool b) return b ? "true" : "false";
if (value is Vector3 v3) return $"{{{v3.x},{v3.y},{v3.z}}}";
if (value is Vector4 v4) return $"{{{v4.x},{v4.y},{v4.z},{v4.w}}}";
return value.ToString();
}
播控与预览
编辑器左下角有一排播控按钮(上一帧 <、播放 ▶、下一帧 >)和帧计数器(当前帧 / 总帧数),右侧还有创建测试模型的入口。这些都在 InitContentLeft() 里初始化:

// ActionSequenceEditorWindow.cs
/// <summary>
/// 初始化左侧播控区域:播放、前后帧按钮、帧计数器。
/// </summary>
private void InitContentLeft()
{
// 播放按钮:先保存,再调 GM 指令让 Lua 侧播放,同时启动编辑器侧进度追踪
Button playBtn = _rootVisualElement.Q<Button>("PlayBtn");
playBtn.clicked += OnClickPlayBtn;
// 前一帧 / 后一帧按钮
Button prevFrameBtn = _rootVisualElement.Q<Button>("PreviouFrameButton");
prevFrameBtn.clicked += OnClickPrevFrame;
Button nextFrameBtn = _rootVisualElement.Q<Button>("NextFrameButton");
nextFrameBtn.clicked += OnClickNextFrame;
// 当前帧输入框(IntegerField)和总帧数输入框
_currentFrameText = _rootVisualElement.Q<IntegerField>("CurrentFrameText");
_frameCountText = _rootVisualElement.Q<IntegerField>("FrameCountText");
}
播放进度追踪
点击播放按钮的流程:先 Save 当前配置 → 通过 GM 指令让运行中的游戏播放该配置 → 启动编辑器侧的进度追踪。
编辑器自身并不执行 ActionSequence 的运行时逻辑,只是通过 EditorApplication.update 注册一个逐帧回调,按 EditorApplication.timeSinceStartup 计算已播放的毫秒数(运行时的时间单位就是毫秒,和 startTime/endTime 一致),实时写入 _currentFrameText.value,到达总帧数或退出 PlayMode 时自动停止:
// ActionSequenceEditorWindow.cs
/// <summary>
/// 是否正在预览播放。
/// </summary>
private bool _isPlaying;
/// <summary>
/// 播放起始时刻(EditorApplication.timeSinceStartup,秒)。
/// </summary>
private double _playStartEditorTime;
/// <summary>
/// 播放按钮回调:先保存,再 GM 调用 Lua 侧播放,最后启动进度追踪。
/// </summary>
private void OnClickPlayBtn()
{
OnClickSave();
if (!EditorApplication.isPlaying) return;
// GM 指令通知 Lua 侧播放,Lua 侧收到后实际执行的就是:
// modelContainer:GetActionSequencePlayer():PlayAction(actionId)
// 也就是前面运行时章节讲的 ActionSequencePlayer.PlayAction()
string actionId = configName;
LuaManager.Instance.RunGm("VisualPlayActionModel", actionId, 1000);
// 编辑器侧启动进度追踪(只追踪时间,不执行实际逻辑)
StartPlayback();
}
/// <summary>
/// 开始进度追踪:重置帧数,注册 EditorApplication.update 回调。
/// </summary>
private void StartPlayback()
{
if (_isPlaying) StopPlayback();
_isPlaying = true;
_playStartEditorTime = EditorApplication.timeSinceStartup;
_currentFrameText.value = 0;
EditorApplication.update += OnPlaybackUpdate;
}
/// <summary>
/// 停止追踪:反注册回调。
/// </summary>
private void StopPlayback()
{
_isPlaying = false;
EditorApplication.update -= OnPlaybackUpdate;
}
/// <summary>
/// 逐帧回调:按真实时间差算当前毫秒数,写入帧计数器,到终点自动停止。
/// 例如 _playStartEditorTime=100.0s,当前 timeSinceStartup=101.5s,
/// 则 currentTimeMs = (101.5 - 100.0) * 1000 = 1500ms。
/// </summary>
private void OnPlaybackUpdate()
{
if (!_isPlaying || !EditorApplication.isPlaying)
{
StopPlayback();
return;
}
int currentTimeMs = (int)((EditorApplication.timeSinceStartup - _playStartEditorTime) * 1000.0);
if (currentTimeMs >= CurrentFrameCount)
{
_currentFrameText.value = CurrentFrameCount;
StopPlayback();
return;
}
_currentFrameText.value = currentTimeMs;
Repaint();
}
前一帧 / 后一帧
步长 = LARGE_VALUE / DefaultFrameRate。这里 LARGE_VALUE = 1000 是数据层的精度系数(后面片段交互部分会详细说),DefaultFrameRate = 10,即每点一次跳 100ms。点击时如果正在播放则先停止,值限制在 [0, CurrentFrameCount] 范围:
// ActionSequenceEditorWindow.cs
/// <summary>
/// 前一帧:当前帧数 - 100ms,不低于 0。
/// </summary>
private void OnClickPrevFrame()
{
if (_isPlaying) StopPlayback();
// LARGE_VALUE=1000, DefaultFrameRate=10 → step=100ms
int step = LARGE_VALUE / DefaultFrameRate;
_currentFrameText.value = Mathf.Max(0, _currentFrameText.value - step);
Repaint();
}
/// <summary>
/// 后一帧:当前帧数 + 100ms,不超过总帧数。
/// </summary>
private void OnClickNextFrame()
{
if (_isPlaying) StopPlayback();
int step = LARGE_VALUE / DefaultFrameRate;
_currentFrameText.value = Mathf.Min(CurrentFrameCount, _currentFrameText.value + step);
Repaint();
}
创建测试模型
编辑器右上方的 CreateTestModelBtn + CreateModelID 输入框 + 选择ModelId 按钮,用来在场景里生成一个表现层模型以便预览技能效果。逻辑分两种情况:
- 编辑器未运行:自动打开测试场景并进入 PlayMode
- 已在运行中:按输入框里的 ModelId 调 GM 指令在场景里生成模型
// ActionSequenceEditorWindow.cs
/// <summary>
/// 创建测试模型:未运行时打开测试场景并 Play,
/// 运行中时按 ModelId 在场景里生成表现层模型。
/// </summary>
private void OnClickCreateTestModel()
{
if (!EditorApplication.isPlaying)
{
// 打开测试场景并进入 PlayMode
EditorSceneManager.OpenScene(testScenePath, OpenSceneMode.Single);
EditorApplication.isPlaying = true;
}
else
{
// 运行态:GM 指令让 Lua 侧按 ModelId 创建一个表现层模型(Entity + ModelContainer + GameObject),
// 后续点播放时就拿这个模型的 ActionSequencePlayer 来执行配置
LuaManager.Instance.RunGm("VisualCreateActionModel", _createModelIdText.value);
}
}
生成模型后,再点播放按钮就能看到该模型执行当前编辑的配置了(如上面 GIF 所示)。
轨道创建与结构

每条轨道在界面上横跨左右两个区域:左边是轨道标题 + 删除按钮,右边是时间轴上的片段条。这两侧各对应一个独立的 UXML 模板,在 UI Builder 里分别长这样:
SingleLineTrackMenu.uxml — 左侧轨道菜单行。结构很简单:一个 Label 作为轨道标题(如 PlayAnimNode 播放动画),里面内嵌一个 Del 按钮用于删除该轨道。

SingleLineTrackContent.uxml — 右侧轨道内容行。就是一个空的 VisualElement 容器(name=AnimationTrackContent),片段块(TrackItem)会在运行时动态挂到它下面。

AnimationTrackItem.uxml — 片段条(Clip)本身。一个 Label 加一个名为 Main 的拖拽区域,运行时由 MainTrackItemStyle 加载并挂到上面 SingleLineTrackContent 的容器里。

创建链路
在看代码之前,先理清楚几个类各自负责什么:
ActionTrackView— 单条轨道的逻辑层,管轨道的生命周期:创建、删除、刷新、持有数据引用。它不直接操作 VisualElement,而是把 UI 的活交给下面两个 Style 类SingleLineTrackStyle— 轨道的外壳样式,负责加载左右两个 UXML 模板(标题行 + 内容行),把它们挂到编辑器的左右父容器里。可以理解为”轨道这一行的骨架”TrackItem— 轨道上的片段(Clip)逻辑层,持有节点数据、处理鼠标事件(拖拽、拉伸、选中),类似 Timeline 里的一个 ClipMainTrackItemStyle— 片段的外观样式,加载AnimationTrackItem.uxml(那个带颜色的条),管理拖拽区域和标题文字。可以理解为”片段条这个 UI 元素本身”
简单来说,”View / Item” 管逻辑和数据,”Style” 管 UXML 加载和 VisualElement 操作,两两配对。
从点击添加按钮到一条完整轨道出现在界面上,调用链路如下:
sequenceDiagram
participant Editor as EditorWindow
participant TV as ActionTrackView
participant TS as SingleLineTrackStyle
participant TI as TrackItem
participant TIS as MainTrackItemStyle
Editor->>Editor: OnClickAddNode()
创建 trackData,解析节点元数据
Editor->>TV: new ActionTrackView()
.Init(menuParent, contentParent, ...)
TV->>TS: new SingleLineTrackStyle()
.Init(menuParent, contentParent, title, onDelete)
TS->>TS: 加载 SingleLineTrackMenu.uxml → 挂到左侧
TS->>TS: 加载 SingleLineTrackContent.uxml → 挂到右侧
TV->>TV: 注册 DragUpdated / DragExited 事件
TV->>TI: CreateItem() → new TrackItem()
.Init(trackView, trackStyle, frameWidth, nodeData)
TI->>TIS: new MainTrackItemStyle()
.Init(trackStyle, frameWidth, startTime, endTime)
TIS->>TIS: 加载 AnimationTrackItem.uxml
TIS->>TS: TrackStyle.AddItem(root) → 片段挂到右侧容器
TI->>TI: 注册 MouseDown / MouseMove / MouseUp 等事件
TI->>TI: ResetView() 计算位置和宽度
四层嵌套创建:EditorWindow → ActionTrackView → SingleLineTrackStyle / TrackItem → MainTrackItemStyle,下面按调用顺序逐层看代码。
SingleLineTrackStyle:加载左右 UXML
SingleLineTrackStyle 是轨道的”外壳样式”,负责把上面两个 UXML 模板实例化并挂到编辑器的左右父容器里。它继承自 TrackStyleBase,基类持有 menuRoot(左侧根节点)和 contentRoot(右侧根节点)两个 VisualElement 引用,以及 AddItem / DeleteItem 方法来管理右侧容器里的子元素。
// SingleLineTrackStyle.cs — 单行轨道样式,加载左右 UXML 模板
public class SingleLineTrackStyle : TrackStyleBase
{
/// <summary>
/// 删除轨道的回调,由 ActionTrackView 传入。
/// </summary>
public Action deleteTrackCb;
/// <summary>
/// 初始化:加载左右 UXML,挂到父容器,绑定标题和删除按钮。
/// </summary>
public void Init(VisualElement menuParent, VisualElement contentParent,
string title, Action deleteAction)
{
this.menuParent = menuParent;
this.contentParent = contentParent;
deleteTrackCb = deleteAction;
// 加载左侧 UXML(Label + Del 按钮)
// Instantiate 会套一层模板容器,取 [1] 跳过外壳直接拿目标节点
menuRoot = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(MENU_UXML_PATH)
.Instantiate().Query().ToList()[1];
menuParent.Add(menuRoot);
// 加载右侧 UXML(空容器,后续 TrackItem 挂进来)
contentRoot = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(CONTENT_UXML_PATH)
.Instantiate().Query().ToList()[1];
contentParent.Add(contentRoot);
// 标题(menuRoot 本身就是 Label)
titleLabel = (Label)menuRoot;
titleLabel.text = title;
// 删除按钮
DeleteTrackBtn = menuRoot.Q<Button>("DeleteTrackBtn");
DeleteTrackBtn.clicked += () => deleteTrackCb?.Invoke();
}
}
注意代码里的 .Query().ToList()[1]——VisualTreeAsset.Instantiate() 返回的根节点并不是 UXML 里我们写的那个元素,而是 UIToolkit 自动包了一层 TemplateContainer。整个层级是这样的:
[0] TemplateContainer ← Instantiate() 返回的根节点,UIToolkit 自动生成的外壳
[1] Label / VisualElement ← 我们在 UXML 里实际定义的元素
所以 Query().ToList() 拿到的 [0] 是那个 TemplateContainer,[1] 才是我们真正要用的节点。如果直接把 [0] 挂到父容器上,会多一层无用的嵌套,后续 Q<T>() 查找子元素时路径也会不对。
SingleLineTrackStyle.Init() 跑完后,编辑器的 VisualElement 树上就多了这样一对节点——左侧一行标题,右侧一行空容器等着片段挂进来:
TrackMenuList(左侧) ContentListView(右侧)
└─ Label (menuRoot) └─ VisualElement (contentRoot)
├─ 轨道标题文字 └─ (空,等待 TrackItem 挂入)
└─ Button "Del"
ActionTrackView:创建样式 + 生成片段
ActionTrackView 是单条轨道的视图层,简单理解就是一条轨道。持有 SingleLineTrackStyle(负责左右 UI 外壳)和 TrackItem(片段块)。Init 时先让 SingleLineTrackStyle 把左右 UXML 挂好,再调 CreateItem 生成片段:
// ActionTrackView.cs — 单条轨道视图,管理样式和片段
public class ActionTrackView : TrackViewBase
{
/// <summary>
/// 轨道样式(左侧标题行 + 右侧内容条)。
/// </summary>
private SingleLineTrackStyle trackStyle;
/// <summary>
/// 轨道上的片段视图,key 为 trackId。
/// </summary>
private Dictionary<int, TrackItem> trackItemDict = new Dictionary<int, TrackItem>();
/// <summary>
/// 关联的轨道数据。
/// </summary>
public ActionTrackData trackData;
/// <summary>
/// 初始化轨道:创建左右 UI 外壳 → 注册拖拽事件 → 生成片段。
/// </summary>
public override void Init(VisualElement menuParent, VisualElement trackParent,
float frameWidth, string lineName, ActionTrackData data)
{
base.Init(menuParent, trackParent, frameWidth, lineName, data);
trackData = data;
// 1. 创建左右两侧 UI 外壳
trackStyle = new SingleLineTrackStyle();
trackStyle.Init(menuParent, trackParent, lineName, () => DestroySelf());
// 2. 在右侧内容条上注册拖拽事件(预留资源拖入扩展)
trackStyle.contentRoot.RegisterCallback<DragUpdatedEvent>(OnDragUpdated);
trackStyle.contentRoot.RegisterCallback<DragExitedEvent>(OnDragExited);
// 3. 当前设计:一个轨道对应一个片段
CreateItem(trackData.TrackId, trackData.NodeData);
ResetView();
}
/// <summary>
/// 创建片段视图:new TrackItem → Init → 加入字典管理。
/// </summary>
private void CreateItem(int itemId, ActionNodeData nodeData)
{
TrackItem item = new TrackItem();
item.Init(this, trackStyle, frameWidth, nodeData);
trackItemDict.Add(itemId, item);
}
}
TrackItem + MainTrackItemStyle:片段的创建
TrackItem 是最终展示在时间轴上的那个”片段条”。它的 Init 方法做三件事:绑定数据、创建 MainTrackItemStyle 加载片段 UXML、注册鼠标事件。
// TrackItem.cs — 片段视图,绑定数据和鼠标事件
public class TrackItem : TrackItemBase<TrackViewBase>
{
/// <summary>
/// 片段对应的节点数据。
/// </summary>
private ActionNodeData nodeData;
/// <summary>
/// 片段条样式(加载 AnimationTrackItem.uxml)。
/// </summary>
private MainTrackItemStyle trackItemStyle;
/// <summary>
/// 初始化:绑定数据 → 创建片段样式 → 注册鼠标事件。
/// </summary>
public void Init(ActionTrackView parentTrack, TrackStyleBase parentTrackStyle,
float frameUnitWidth, ActionNodeData actionNodeData)
{
track = parentTrack;
frameIndex = actionNodeData.StartTime;
this.frameUnitWidth = frameUnitWidth;
this.nodeData = actionNodeData;
// 创建片段条样式,加载 AnimationTrackItem.uxml 并挂到右侧容器
trackItemStyle = new MainTrackItemStyle();
trackItemStyle.Init(parentTrackStyle, frameUnitWidth,
actionNodeData.StartTime, actionNodeData.EndTime);
itemStyle = trackItemStyle;
// 注册鼠标事件(拖拽、拉伸、选中,后面 片段交互 章节详细说)
trackItemStyle.mainDragArea.RegisterCallback<MouseDownEvent>(OnMouseDown);
trackItemStyle.mainDragArea.RegisterCallback<MouseMoveEvent>(OnMouseMove);
trackItemStyle.mainDragArea.RegisterCallback<MouseUpEvent>(OnMouseUp);
ResetView(frameUnitWidth);
}
}
MainTrackItemStyle 负责加载前面看到的 AnimationTrackItem.uxml,然后通过 TrackStyle.AddItem(root) 把自己挂到右侧 contentRoot 容器里:
// MainTrackItemStyle.cs — 片段条样式:背景色 + 标题文字
public class MainTrackItemStyle : TrackItemStyleBase
{
/// <summary>
/// 片段标题 Label。
/// </summary>
private Label titleLabel;
/// <summary>
/// 拖拽区域(鼠标事件绑在这个元素上)。
/// </summary>
public VisualElement mainDragArea { get; private set; }
/// <summary>
/// 初始化:加载 UXML,取出关键元素,挂到轨道的右侧容器。
/// </summary>
public void Init(TrackStyleBase trackStyle, float frameUnitWidth,
int startTime, int endTime)
{
// 加载片段 UXML,根节点是一个 Label
root = titleLabel = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(ITEM_UXML_PATH)
.Instantiate().Query<Label>();
mainDragArea = root.Q<VisualElement>("Main");
// 挂到右侧容器(调用基类的 AddItem)
trackStyle.AddItem(root);
titleLabel.style.unityFontStyleAndWeight = FontStyle.Bold;
}
public void SetTitle(string title)
{
titleLabel.text = title;
}
}
四层 Init 跑完后,一条完整轨道在 VisualElement 树上长这样:
graph TD
subgraph 左侧TrackMenuList
MR["menuRoot (Label)
轨道标题文字"]
MR --> DEL["DeleteTrackBtn (Button)
Del 删除按钮"]
end
subgraph 右侧ContentListView
CR["contentRoot (VisualElement)
由 SingleLineTrackStyle 加载"]
CR --> ATI["AnimationTrackItem (Label)
由 MainTrackItemStyle 加载"]
ATI --> MAIN["mainDragArea (name=Main)
片段条的可交互区域
拖拽/拉伸/选中事件绑在这里"]
end
左侧TrackMenuList ~~~ 右侧ContentListView
style MR fill:#3a7,stroke:#333,color:#fff
style CR fill:#37a,stroke:#333,color:#fff
style ATI fill:#5a9,stroke:#333,color:#fff
左边绿色部分由 SingleLineTrackStyle 负责,右边蓝色部分中 contentRoot 也是它加载的,而 contentRoot 下面的片段条则是 MainTrackItemStyle 通过 TrackStyle.AddItem(root) 挂进去的。片段的位置和宽度由 TrackItem.ResetView() 根据 startTime、endTime 和 frameUnitWidth 计算。
批量创建与滚动同步
加载已有配置时,InitAllLine 遍历 sequenceData.TrackDataDict,为每条轨道数据走一遍上面的创建流程:
// ActionSequenceEditorWindow.cs
/// <summary>
/// 遍历所有轨道数据,逐条创建轨道视图。
/// </summary>
void InitAllLine()
{
foreach (KeyValuePair<int, ActionTrackData> kv in sequenceData.TrackDataDict)
{
ActionTrackView trackView = new ActionTrackView();
trackView.Init(_trackMenuList, _contentListView,
editorConfig.FrameUnitWidth, kv.Value.NodeData.NodeType, kv.Value);
_trackViewDict.Add(kv.Key, trackView);
}
}
因为左侧标题列表和右侧内容列表是两个独立的 ScrollView,滚动时需要同步——左边滚右边跟着动,反之亦然,否则轨道标题和片段条会错位。做法是互相监听 verticalScroller.valueChanged,在回调里把对方的 scrollOffset.y 设成一样的值。注意要加一个 _isSyncingScroll 标志位防止互相触发死循环:
// ActionSequenceEditorWindow.cs
/// <summary>
/// 防止左右滚动同步互相触发的标志位。
/// </summary>
private bool _isSyncingScroll;
/// <summary>
/// 绑定左右 ScrollView 的垂直滚动同步。
/// </summary>
private void BindScrollSync()
{
// 右侧滚动 → 同步左侧
_mainContentView.verticalScroller.valueChanged += _ =>
SyncVerticalScroll(_mainContentView, _trackMenuScrollView);
// 左侧滚动 → 同步右侧
_trackMenuScrollView.verticalScroller.valueChanged += _ =>
SyncVerticalScroll(_trackMenuScrollView, _mainContentView);
}
/// <summary>
/// 把 source 的垂直滚动位置同步到 target,只改 y 不动 x。
/// </summary>
private void SyncVerticalScroll(ScrollView source, ScrollView target)
{
if (_isSyncingScroll) return;
_isSyncingScroll = true;
Vector2 offset = target.scrollOffset;
offset.y = source.scrollOffset.y;
target.scrollOffset = offset;
_isSyncingScroll = false;
}
类层级总览
用类图理一下轨道相关的继承和组合关系:
classDiagram
direction TB
class TrackViewBase {
<>
+Init() 初始化
+ResetView() 刷新视图
+Destroy() 销毁
}
class ActionTrackView {
<<单条轨道视图>>
-SingleLineTrackStyle trackStyle 轨道样式
-Dictionary~int, TrackItem~ trackItemDict 片段字典
+ActionTrackData trackData 轨道数据
+Init() 创建样式、生成片段
-CreateItem() 创建单个片段
-DestroySelf() 删除轨道
}
class TrackStyleBase {
<>
+VisualElement menuRoot 左侧根节点
+VisualElement contentRoot 右侧根节点
+AddItem() 添加片段到右侧
+DeleteItem() 移除片段
}
class SingleLineTrackStyle {
<<单行轨道样式>>
+Init() 加载左右 UXML 模板
+DeleteTrack() 触发删除回调
}
class TrackItemBase {
<>
+float frameUnitWidth 每帧像素宽
+Select() 选中
+ResetView() 刷新位置和宽度
}
class TrackItem {
<<片段视图>>
-ActionNodeData nodeData 节点数据
-MainTrackItemStyle trackItemStyle 片段样式
+Init() 绑定鼠标事件
+Select() 通知 Inspector 显示属性
}
class MainTrackItemStyle {
<<片段条样式>>
+VisualElement mainDragArea 拖拽区域
+Label titleLabel 标题
+Init() 加载 AnimationTrackItem.uxml
+SetTitle() 设置显示文字
}
TrackViewBase <|-- ActionTrackView : 继承
TrackStyleBase <|-- SingleLineTrackStyle : 继承
TrackItemBase <|-- TrackItem : 继承
ActionTrackView o-- SingleLineTrackStyle : 持有样式
ActionTrackView o-- TrackItem : 持有片段
TrackItem o-- MainTrackItemStyle : 持有样式
时间刻度尺
时间刻度尺和当前帧指示线(SelectLine)是编辑器中用 IMGUI 画的两个部分。UIToolkit 没有原生的刻度尺控件,而 IMGUI 的 Handles.DrawLine 和 GUI.Label 画线画字很方便,所以在 UXML 里放了一个 IMGUIContainer(name=TimeShaft),初始化时把它的 onGUIHandler 指向绘制方法:
// ActionSequenceEditorWindow.cs — InitContentRight 片段
TimeShaft = _rootVisualElement.Q<IMGUIContainer>("TimeShaft");
TimeShaft.onGUIHandler = OnDrawTimeRuler;
刻度尺需要处理两种交互:横向滚动(改变看到的帧范围)和 Ctrl + 缩放(改变每帧占多少像素),下面分别说。
横向滚动与刻度绘制
内容区是一个 ScrollView,用户 Shift + 滚轮(或拖动横向滚动条)会水平滚动,ScrollView 自己就会处理。刻度尺要做的事情是:每帧重绘时读取当前的滚动偏移量 contentOffsetPos,算出可见区域里该从第几帧开始画、从哪个像素位置开始画。
举个例子。假设 FrameUnitWidth = 100px(每帧占 100 像素),用户向右滚动了 contentOffsetPos = 150px。
先看全局视角——整条时间轴上每帧占 100px,滚过了 150px,所以可见区域的左边界落在帧 1 和帧 2 之间:
全局像素: 0 100 200 300 400
帧号: 0 1 2 3 4
├─────────┼──────────┼──────────┼──────────┤
↑ ↑
│ └─ 帧2 位置 (200px)
│
|←── 150px ────→|
contentOffsetPos │
└─ 可见区域从这里开始
再看可见区域内部——刻度尺的绘制坐标从 x=0 开始(局部坐标),帧 2 在可见区域里的位置是 200 - 150 = 50px,这就是 startOffset,第一条刻度线从这里开始画:
可见区域内(局部坐标 x 从 0 开始):
x: 0 50 150 250
├──────────╫──────────┼───────────╫──────────
│ 无刻度 ║ 帧2 │ 帧3 ║ 帧4
│ ║ │ ║
↑ ↑
startOffset=50 长刻度(tickStep倍数)
第一条刻度线从这画
对应到代码里:
index = CeilToInt(150 / 100) = 2— 第一个可见帧是帧 2startOffset = 100 - (150 % 100) = 100 - 50 = 50px— 帧 2 在可见区域内的 x 坐标
完整绘制代码:
// ActionSequenceEditorWindow.cs
/// <summary>
/// 绘制时间刻度尺。IMGUIContainer 每帧调用一次,
/// 根据 contentOffsetPos 和 FrameUnitWidth 算出可见帧范围并画刻度。
/// </summary>
private void OnDrawTimeRuler()
{
Handles.BeginGUI();
Handles.color = Color.white;
Rect rect = TimeShaft.contentRect;
// 1. 算出第一个可见帧的索引
// 沿用上面的例子:CeilToInt(150 / 100) = 2,可见区域从帧 2 开始
int index = Mathf.CeilToInt(contentOffsetPos / editorConfig.FrameUnitWidth);
// 2. 算出第一条刻度线在可见区域内的 x 坐标(startOffset)
// 100 - (150 % 100) = 100 - 50 = 50px,帧 2 的刻度画在 x=50
float startOffset = 0;
if (index > 0)
startOffset = editorConfig.FrameUnitWidth
- (contentOffsetPos % editorConfig.FrameUnitWidth);
// 3. 动态计算长刻度步长(tickStep)
// 先解释三个配置值:
// StandFrameUnitWidth = 20 最小帧宽(缩放下限),即一帧最少占 20px
// MaxFrameWidthLV = 20 缩放级数,最大帧宽 = 20 × 20 = 400px
// FrameUnitWidth 当前帧宽,范围 [20, 400],受 Ctrl+滚轮缩放而变化
//
// tickStep 表示每隔几帧画一条长刻度(带时间数字),其余帧只画短线
// 比如当前 FrameUnitWidth=100:
// → tickStep = (20+1 - 100/20) / 2 = (21-5) / 2 = 8
// → 帧 0, 8, 16, 24... 画长刻度标数字,帧 1~7, 9~15... 只画短线
// 帧宽越小(缩得越远)→ tickStep 越大 → 长刻度越稀疏,避免数字挤在一起
int tickStep = editorConfig.MaxFrameWidthLV + 1
- (editorConfig.FrameUnitWidth / editorConfig.StandFrameUnitWidth);
tickStep = Mathf.Clamp(tickStep / 2, 1, editorConfig.MaxFrameWidthLV);
// 4. 从 startOffset(=50) 开始,每隔 FrameUnitWidth(=100) 画一条刻度线
// x 依次为 50, 150, 250, 350 ... 对应帧 2, 3, 4, 5 ...
for (float x = startOffset; x < rect.width; x += editorConfig.FrameUnitWidth)
{
if (index % tickStep == 0)
{
// 长刻度:从底部往上画 10px 的线,顶部标时间数字
Handles.DrawLine(new Vector3(x, rect.height - 10), new Vector3(x, rect.height));
string label = (index * LARGE_VALUE).ToString();
GUI.Label(new Rect(x - label.Length * 4.5f, 0, 55, 20), label);
}
else
{
// 短刻度:只画 5px 的短线
Handles.DrawLine(new Vector3(x, rect.height - 5), new Vector3(x, rect.height));
}
index++;
}
Handles.EndGUI();
}
因为 IMGUIContainer 每帧都会调 onGUIHandler,所以用户滚动后 contentOffsetPos 变了,刻度尺下一帧自动就重绘了,不需要手动触发。
Ctrl + 滚轮缩放
缩放改变的是 FrameUnitWidth(每帧占多少像素),帧号本身不变。用同样的例子对比一下缩放前后:
缩放前:FrameUnitWidth = 100px
帧0 帧1 帧2 帧3 帧4
├────────┼────────┼────────┼────────┤
0 100 200 300 400 (px)
|←100px→|
缩放后:FrameUnitWidth = 50px(Ctrl + 向下滚)
帧0 帧1 帧2 帧3 帧4 帧5 帧6 帧7 帧8
├────┼────┼────┼────┼────┼────┼────┼────┤
0 50 100 150 200 250 300 350 400 (px)
|←50→|
FrameUnitWidth 减半后,同样 400px 宽度里能看到 8 帧而不是 4 帧,时间轴就”缩远”了。反过来 Ctrl + 向上滚增大 FrameUnitWidth,帧间距变宽,时间轴”放大”。
事件注册:刻度尺上滚轮直接缩放(刻度尺没有滚动需求);内容区先检查 evt.ctrlKey,按了 Ctrl 才缩放,否则放行给 ScrollView 正常滚动。内容区注册时用了 TrickleDown 优先捕获,不然事件会先被 ScrollView 吃掉:
// ActionSequenceEditorWindow.cs — InitContentRight 片段
// 刻度尺上滚轮直接缩放
TimeShaft.RegisterCallback<WheelEvent>(OnTimeShaftWheel);
// 内容区:Ctrl + 滚轮才缩放,用 TrickleDown 优先捕获
ScrollView mainContentView = _rootVisualElement.Q<ScrollView>("MainContentView");
mainContentView.RegisterCallback<WheelEvent>(OnContentWheelZoom, TrickleDown.TrickleDown);
// ActionSequenceEditorWindow.cs
/// <summary>
/// 内容区滚轮事件:按住 Ctrl 才缩放,否则不拦截,让 ScrollView 正常滚动。
/// </summary>
private void OnContentWheelZoom(WheelEvent evt)
{
if (!evt.ctrlKey) return;
ZoomTimeShaftByWheel(evt);
evt.StopImmediatePropagation();
evt.PreventDefault();
}
/// <summary>
/// 滚轮缩放:根据 delta 修改 FrameUnitWidth,Clamp 到合法范围,
/// 然后刷新刻度尺、内容区宽度和所有轨道片段。
/// </summary>
private void ZoomTimeShaftByWheel(WheelEvent evt)
{
// delta.y > 0 → 向下滚 → 帧宽减小(缩小)
// delta.y < 0 → 向上滚 → 帧宽增大(放大)
// 沿用上面的例子:当前 FrameUnitWidth=100,连续向下滚 delta 累计 50:
// 新帧宽 = 100 - 50 = 50,和上面缩放对比图一致
int delta = (int)evt.delta.y;
// Clamp 到 [20, 400],不会缩到看不见,也不会放大到一帧占满屏幕
editorConfig.FrameUnitWidth = Mathf.Clamp(
editorConfig.FrameUnitWidth - delta,
editorConfig.StandFrameUnitWidth, // 下限 20px
editorConfig.MaxFrameWidthLV * editorConfig.StandFrameUnitWidth // 上限 20×20 = 400px
);
// 标记刻度尺和游标需要重绘(IMGUIContainer 下一帧自动调 onGUIHandler)
TimeShaft.MarkDirtyLayout();
SelectLine.MarkDirtyLayout();
// 内容区总宽度 = 帧宽 × 总帧数
// 缩放前:100帧 × 100px = 10000px,缩放后:100帧 × 50px = 5000px
UpdateContentSize();
// 所有轨道片段按新帧宽重算位置和宽度
// 比如某片段 startTime=2000,缩放前 x = 2000×100/1000 = 200px
// 缩放后 x = 2000×50/1000 = 100px,视觉上往左缩了一半
foreach (TrackViewBase trackView in _trackViewDict.Values)
trackView.ResetView(editorConfig.FrameUnitWidth);
}
UpdateContentSize 就只是内容区总宽度等于帧宽乘总帧数,ScrollView 据此更新滚动条范围:
// ActionSequenceEditorWindow.cs
/// <summary>
/// 更新内容区总宽度,保证滚动条范围和缩放一致。
/// 缩放前:100 × 100 = 10000px,缩放后:50 × 100 = 5000px
/// </summary>
private void UpdateContentSize()
{
_contentListView.style.width = editorConfig.FrameUnitWidth * CurrentFrameCount;
}
ResetView 也很直接——按新帧宽重算片段的像素位置和宽度:
// TrackItem.cs
/// <summary>
/// 刷新片段视图:标题、位置、宽度。
/// 数据层的 startTime/endTime 不变,只是乘数(FrameUnitWidth)变了。
/// 比如 startTime=2000, FrameUnitWidth 从 100 变成 50:
/// 位置:2000 × 100 / 1000 = 200px → 2000 × 50 / 1000 = 100px
/// 宽度同理缩半
/// </summary>
public override void ResetView(float frameUnitWidth)
{
this.frameUnitWidth = frameUnitWidth;
trackItemStyle.SetTitle(GetNodeDisplayTitle());
trackItemStyle.SetPosition(nodeData.StartTime * frameUnitWidth / LARGE_VALUE);
trackItemStyle.SetWidth((nodeData.EndTime - nodeData.StartTime) / LARGE_VALUE * frameUnitWidth);
}
所以缩放本质上就是改 FrameUnitWidth 这一个乘数,数据层不动,视图层全部重算一遍。刻度尺同理——OnDrawTimeRuler 里用的也是 FrameUnitWidth,变了下一帧重绘时刻度间距自然跟着变。
当前帧指示线 SelectLine
在右侧内容区域还有一条白色竖线,标记当前帧的位置。播放时它会跟着帧数移动,点前一帧/后一帧时也会跳到对应位置(效果见前面播控部分的 GIF)。
实现上和刻度尺一样用 IMGUI——UXML 里有一个 IMGUIContainer(name=SelectLine),覆盖在整个右侧面板上方,设 pickingMode = PickingMode.Ignore 让鼠标事件穿透(不影响下面片段的拖拽操作):
// ActionSequenceEditorWindow.cs — InitContentRight 片段
SelectLine = _rootVisualElement.Q<IMGUIContainer>("SelectLine");
// 拉满覆盖整个右侧面板
SelectLine.style.top = 0;
SelectLine.style.bottom = 0;
SelectLine.style.right = 0;
// 鼠标事件穿透,不阻碍下面片段的拖拽/选中
SelectLine.pickingMode = PickingMode.Ignore;
SelectLine.onGUIHandler = OnGUISelectLine;
绘制逻辑和刻度尺、片段位置用的是同一套公式——当前毫秒数 × editorConfig.FrameUnitWidth / LARGE_VALUE 算出全局像素位置,再减去滚动偏移 contentOffsetPos 得到屏幕上的 x 坐标:
// ActionSequenceEditorWindow.cs
/// <summary>
/// 绘制当前帧位置的白色竖线。
/// 位置公式与 TrackItem.ResetView 一致:
/// xPos = currentTimeMs * editorConfig.FrameUnitWidth / LARGE_VALUE - contentOffsetPos
/// 例如当前帧 1500ms,FrameUnitWidth=100,LARGE_VALUE=1000:
/// 全局像素 = 1500 * 100 / 1000 = 150px
/// 若 contentOffsetPos=50,则屏幕 x = 150 - 50 = 100px
/// </summary>
private void OnGUISelectLine()
{
if (_currentFrameText == null) return;
int currentTimeMs = _currentFrameText.value;
if (currentTimeMs < 0) return;
Rect rect = SelectLine.contentRect;
if (rect.width <= 0 || rect.height <= 0) return;
float xPos = currentTimeMs * editorConfig.FrameUnitWidth / LARGE_VALUE - contentOffsetPos;
// 超出可见范围就不画
if (xPos < 0 || xPos > rect.width) return;
Handles.BeginGUI();
Handles.color = Color.white;
Handles.DrawLine(new Vector3(xPos, 0), new Vector3(xPos, rect.height));
Handles.EndGUI();
}
_currentFrameText.value 就是前面播控部分不断更新的那个值——播放时 OnPlaybackUpdate 写入,前后帧按钮点击时也会改,所以 SelectLine 每帧重绘时读到的都是最新值,线就跟着动了。缩放时 FrameUnitWidth 变了,线的位置也会自动重算。
Inspector 面板
点击一个片段,右侧 Unity Inspector 窗口就会显示该片段的属性面板,可以直接编辑开始/结束时间、节点参数等:

选中流程
调用链路:用户点击片段 → TrackItem.Select() → ShowTrackItemOnInspector(this, track) → Inspector 的静态方法 SetTrackItem 记录当前选中项,并调 Show() 刷新面板。
这里用到了 Unity 的 [CustomEditor] 机制——给 Inspector 类加上 [CustomEditor(typeof(ActionSequenceEditorWindow))],当编辑器窗口被选中时,Unity 自动用这个类来画 Inspector 面板。
这个类只在两个地方用到了 Odin:
- **继承
OdinEditor**(Odin 提供的 Editor 基类,替代 Unity 原生的UnityEditor.Editor),好处是如果有[ShowInInspector]等 Odin 特性的字段也能正常显示 - 其他都是 Unity 原生 API:
[CustomEditor]是 Unity 的特性,CreateInspectorGUI()是 Unity 的虚方法(返回VisualElement根节点),所有控件(TextField、Toggle、IntegerField、Vector3Field等)和事件(RegisterValueChangedCallback、SetValueWithoutNotify)全部来自 UIToolkit
完整的类结构:
// ActionEditorInspector.cs — 编辑器 Inspector 面板
// [CustomEditor] —— Unity 原生特性,指定这个类为 ActionSequenceEditorWindow 的自定义 Inspector
[CustomEditor(typeof(ActionSequenceEditorWindow))]
// OdinEditor —— Odin 提供的 Editor 基类(唯一用到 Odin 的地方)
public class ActionEditorInspector : OdinEditor
{
/// <summary>
/// 单例,方便外部访问(如拖拽时同步更新时间显示)。
/// </summary>
public static ActionEditorInspector Instance;
/// <summary>
/// 当前选中的片段。
/// </summary>
private static TrackItem currentTrackItem;
/// <summary>
/// 当前选中片段所属的轨道。
/// </summary>
private static TrackViewBase currentTrack;
/// <summary>
/// UIToolkit 的 UI 根节点,所有控件挂在这下面。
/// </summary>
private VisualElement root;
/// <summary>
/// 反注册回调列表,Clean() 时统一反注册,防止内存泄漏。
/// </summary>
private List<Action> unregisterActions = new List<Action>();
// ---- UIToolkit 控件引用(拖拽时需要 SetValueWithoutNotify 更新显示)----
/// <summary>
/// UIToolkit TextField —— 开始时间输入框。
/// </summary>
private TextField startFrameField;
/// <summary>
/// UIToolkit TextField —— 结束时间输入框。
/// </summary>
private TextField endFrameField;
/// <summary>
/// 重写 Unity 的 CreateInspectorGUI(),返回 UIToolkit 的 VisualElement 根节点。
/// </summary>
public override VisualElement CreateInspectorGUI()
{
Instance = this;
root = new VisualElement();
Show();
return root;
}
/// <summary>
/// 外部调用:选中新片段时,取消旧选中,记录新选中,刷新面板。
/// </summary>
public static void SetTrackItem(TrackItem trackItem, TrackViewBase track)
{
currentTrackItem?.OnUnSelect();
currentTrackItem = trackItem;
currentTrackItem.OnSelect();
currentTrack = track;
Instance?.Show();
}
/// <summary>
/// 外部调用:拖拽过程中实时更新时间显示。
/// 用 UIToolkit 的 SetValueWithoutNotify() 更新文本,不触发 ValueChangedCallback。
/// </summary>
public static void UpdateSelectedTrackItemTime(TrackItem trackItem, int startTime, int endTime)
{
if (Instance == null || currentTrackItem != trackItem) return;
Instance.startFrameField?.SetValueWithoutNotify(startTime.ToString());
Instance.endFrameField?.SetValueWithoutNotify(endTime.ToString());
}
/// <summary>
/// 清空面板:反注册所有 UIToolkit 回调,移除所有子控件。
/// </summary>
private void Clean()
{
foreach (Action unregister in unregisterActions)
unregister();
unregisterActions.Clear();
for (int i = root.childCount - 1; i >= 0; i--)
root.RemoveAt(i);
}
/// <summary>
/// 刷新面板:先 Clean 旧内容,再按当前选中片段的数据重新生成 UIToolkit 控件。
/// </summary>
private void Show()
{
Clean();
if (currentTrackItem == null) return;
DrawTrackItemPanel((TrackItem)currentTrackItem);
}
// ... DrawTrackItemPanel、CreateParamField 等方法见下文
}
面板绘制
DrawTrackItemPanel 按照截图中从上到下的顺序,逐个创建控件并挂到 root 上。每个控件都注册 ValueChangedCallback,用户在 Inspector 里修改值时即时回写到 nodeData,同时把反注册动作存进 unregisterActions 列表:
// ActionEditorInspector.cs
/// <summary>
/// 绘制选中片段的属性面板(全部使用 UIToolkit 控件)。
/// 对照截图从上到下:开始时间 → 结束时间 → endNotAction → 节点类型 → 动态参数 → 删除按钮。
/// </summary>
private void DrawTrackItemPanel(TrackItem trackItem)
{
ActionNodeData nodeData = trackItem.nodeData;
// 1. 开始时间输入框 —— UIToolkit.TextField
startFrameField = new TextField("开始时间:");
startFrameField.value = nodeData.StartTime.ToString();
// RegisterValueChangedCallback —— UIToolkit 的值变化回调
startFrameField.RegisterValueChangedCallback(OnStartTimeChanged);
// 存一份反注册 lambda,Clean() 时统一调用
unregisterActions.Add(() => startFrameField.UnregisterValueChangedCallback(OnStartTimeChanged));
root.Add(startFrameField);
// 2. 结束时间输入框 —— UIToolkit.TextField
endFrameField = new TextField("结束时间:");
endFrameField.value = nodeData.EndTime.ToString();
endFrameField.RegisterValueChangedCallback(OnEndTimeChanged);
unregisterActions.Add(() => endFrameField.UnregisterValueChangedCallback(OnEndTimeChanged));
root.Add(endFrameField);
// 3. "结束不执行动作" 开关 —— UIToolkit.Toggle
Toggle endNotActionToggle = new Toggle("endNotAction:");
endNotActionToggle.value = nodeData.endNotAction;
endNotActionToggle.RegisterValueChangedCallback(OnEndNotActionChanged);
unregisterActions.Add(() => endNotActionToggle.UnregisterValueChangedCallback(OnEndNotActionChanged));
root.Add(endNotActionToggle);
// 4. 节点类型标签(只读)—— UIToolkit.Label
string displayType = $"{nodeData.NodeType} {nodeData.NodeParamMeta?.ChineseName}";
root.Add(new Label("Action类型:" + displayType));
// 5. 根据 NodeParamMeta 动态生成参数控件 —— 全部是 UIToolkit 控件
// 每个节点的参数不同(比如 PlayAnimNode 有 animName、layerId),
// 遍历元数据列表逐个创建对应类型的控件
if (nodeData.NodeParamMeta != null)
{
foreach (NodeParamFieldMeta fieldMeta in nodeData.NodeParamMeta.NodeParamFieldMetaList)
{
object value = nodeData.GetParamValue(fieldMeta.name);
VisualElement field = CreateParamField(fieldMeta, value);
root.Add(field);
}
}
// 6. 删除按钮 —— UIToolkit.Button
Button deleteBtn = new Button(() => DeleteTrack());
deleteBtn.text = "删除";
deleteBtn.style.backgroundColor = new Color(1, 0, 0, 0.5f);
root.Add(deleteBtn);
}
动态参数控件
CreateParamField 里按 NodeParamFieldMeta.type 做 switch,不同类型创建不同的 UIToolkit 控件。对应关系:
| type | UIToolkit 控件 | 说明 |
|---|---|---|
int |
IntegerField |
整数输入 |
long |
LongField |
大数值(如 Entity ID) |
float |
FloatField |
浮点输入 |
bool |
Toggle |
布尔开关 |
string |
TextField |
文本输入 |
vector3 |
Vector3Field |
三维向量 |
vector4 |
Vector4Field |
四维向量 |
以上控件全部来自 UnityEngine.UIElements 命名空间,和 Odin 无关。实现伪代码:
// ActionEditorInspector.cs
/// <summary>
/// 根据参数元数据的类型,创建对应的 UIToolkit 输入控件。
/// 所有控件(IntegerField、Toggle、Vector3Field 等)均来自 UnityEngine.UIElements,
/// 与 Odin 无关。每个控件都绑定 UIToolkit 的 ValueChangedCallback,值变化时即时回写。
/// </summary>
private VisualElement CreateParamField(NodeParamFieldMeta fieldMeta, object currentValue)
{
// fieldMeta.GetDisplayLabel() 返回 "字段名 (中文描述)" 格式的标签
string label = fieldMeta.GetDisplayLabel();
switch (fieldMeta.type.ToLower())
{
case "int":
{
// UIToolkit.IntegerField —— 整数输入框
IntegerField field = new IntegerField(label);
field.value = Convert.ToInt32(currentValue);
field.RegisterValueChangedCallback(evt =>
currentTrackItem.nodeData.SetParamValue(fieldMeta.name, evt.newValue));
return field;
}
case "long":
{
// UIToolkit.LongField —— 长整数输入框(Entity ID 等大数值)
LongField field = new LongField(label);
field.value = Convert.ToInt64(currentValue);
field.RegisterValueChangedCallback(evt =>
currentTrackItem.nodeData.SetParamValue(fieldMeta.name, evt.newValue));
return field;
}
case "float":
{
// UIToolkit.FloatField —— 浮点输入框
FloatField field = new FloatField(label);
field.value = Convert.ToSingle(currentValue);
field.RegisterValueChangedCallback(evt =>
currentTrackItem.nodeData.SetParamValue(fieldMeta.name, evt.newValue));
return field;
}
case "bool":
{
// UIToolkit.Toggle —— 布尔开关
Toggle field = new Toggle(label);
field.value = currentValue as bool? ?? false;
field.RegisterValueChangedCallback(evt =>
currentTrackItem.nodeData.SetParamValue(fieldMeta.name, evt.newValue));
return field;
}
case "string":
{
// UIToolkit.TextField —— 文本输入框
TextField field = new TextField(label);
field.value = currentValue as string ?? "";
field.RegisterValueChangedCallback(evt =>
currentTrackItem.nodeData.SetParamValue(fieldMeta.name, evt.newValue));
return field;
}
case "vector3":
{
// UIToolkit.Vector3Field —— 三维向量输入框
Vector3Field field = new Vector3Field(label);
field.value = ParseVector3(currentValue);
field.RegisterValueChangedCallback(evt =>
currentTrackItem.nodeData.SetParamValue(fieldMeta.name, evt.newValue));
return field;
}
case "vector4":
{
// UIToolkit.Vector4Field —— 四维向量输入框
Vector4Field field = new Vector4Field(label);
field.value = ParseVector4(currentValue);
field.RegisterValueChangedCallback(evt =>
currentTrackItem.nodeData.SetParamValue(fieldMeta.name, evt.newValue));
return field;
}
default:
Debug.LogWarning($"未知参数类型: {fieldMeta.type}, 参数名: {fieldMeta.name}");
return null;
}
}
每种 case 的逻辑都一样:new UIToolkit 控件 → 设初始值 → 绑 RegisterValueChangedCallback 回写 → 返回。Vector3/Vector4 需要额外处理类型转换(currentValue 可能是 float[]、逗号分隔的字符串等),这里用 ParseVector3/ParseVector4 做兼容解析。
这里没有用 Odin 的 PropertyTree 来画 Inspector,因为节点的参数列表是运行时从 Lua 注释里解析出来的元数据(NodeParamMeta),不是 C# 编译期的静态字段,Odin 的自动序列化/绘制用不上,所以全部用 UIToolkit 控件手动拼装。Clean() 时遍历 unregisterActions 统一反注册回调,避免切换片段时旧回调还在触发。
片段交互:拖拽与拉伸
先看一下实际效果——片段支持整体拖拽、左右边缘拉伸改变时长:

片段(TrackItem)的交互逻辑全部绑在 mainDragArea 上,Init 时注册五个鼠标事件:
// TrackItem.cs — Init 里绑定鼠标事件
trackItemStyle.mainDragArea.RegisterCallback<MouseDownEvent>(OnMouseDown);
trackItemStyle.mainDragArea.RegisterCallback<MouseUpEvent>(OnMouseUp);
trackItemStyle.mainDragArea.RegisterCallback<MouseMoveEvent>(OnMouseMove);
trackItemStyle.mainDragArea.RegisterCallback<MouseOutEvent>(OnMouseOut);
trackItemStyle.mainDragArea.RegisterCallback<MouseCaptureOutEvent>(OnMouseCaptureOut);
三种交互模式
鼠标按下时,根据按下位置决定进入哪种模式。先说一下 LARGE_VALUE:数据层的时间单位不是”帧”而是毫秒级精度,LARGE_VALUE = 1000 是精度系数。比如 startTime=2000 实际对应的是第 2 帧(2000 / 1000 = 2),这样做是为了支持亚帧级的精度控制。
用一个例子说明——假设有一个片段 startTime=2000, endTime=5000(即第 2 帧到第 5 帧),当前 FrameUnitWidth=100:
片段位置 = 2000 × 100 / 1000 = 200px
片段宽度 = (5000-2000) / 1000 × 100 = 300px
在界面上这个片段从 x=200 画到 x=500,宽 300px。片段的左右两端各有 6px 的拉伸热区:
200px 500px
↓ ↓
┌──────┬─────────────────────────────┬──────┐
│←6px→ │ 可拖拽区域(中间) │ ←6px→│
│左拉伸 │ 长按 0.1s 后整体平移 │右拉伸 │
└──────┴─────────────────────────────┴──────┘
三种模式的效果(假设鼠标向右移动了 50px,对应 offsetFrame = RoundToInt(50 × 1000 / 100) = 500):
| 模式 | 触发条件 | startTime | endTime | 说明 |
|---|---|---|---|---|
| 普通拖拽 | 按在中间区域,长按 0.1s | 2000 → 2500 | 5000 → 5500 | 整体右移 500,时长不变 |
| 右侧拉伸 | 按在右边缘 6px 内 | 2000(不变) | 5000 → 5500 | 只改结束时间,片段变长 |
| 左侧拉伸 | 按在左边缘 6px 内 | 2000 → 2500 | 5000(不变) | 只改开始时间,片段变短 |
交互实现
OnMouseDown 记录按下时的起始状态,并判断是否落在拉伸热区:
// TrackItem.cs
/// <summary>
/// 鼠标按下:记录起始状态,判断拉伸热区,捕获鼠标。
/// </summary>
private void OnMouseDown(MouseDownEvent evt)
{
mouseDown = true;
mouseDownTime = Time.realtimeSinceStartup;
mouseDrag = false;
// 记录按下时的鼠标位置和片段时间,后续拖拽时用来算偏移
// 沿用例子:startDragPosX=350, startDragStartTime=2000, startDragEndTime=5000
startDragPosX = evt.mousePosition.x;
startDragStartTime = nodeData.StartTime;
startDragEndTime = nodeData.EndTime;
startDragDuration = nodeData.EndTime - nodeData.StartTime; // = 3000
// 判断按下位置是否在左右边缘 6px 内
// 比如按在 localX=296(元素宽 300),296 >= 300-6 = true → 右拉伸
isResizeRight = IsOnRightResizeEdge(evt);
isResizeLeft = IsOnLeftResizeEdge(evt);
// 捕获鼠标,防止快速拖拽时鼠标移出元素后丢事件
trackItemStyle.mainDragArea.CaptureMouse();
Select();
}
OnMouseMove 里先检查长按阈值(0.1s),达到后才进入拖拽,避免单击选中时误触移动。然后根据模式分别更新数据:
// TrackItem.cs
/// <summary>
/// 鼠标移动:长按 0.1s 后进入拖拽,根据模式更新数据和视图。
/// </summary>
private void OnMouseMove(MouseMoveEvent evt)
{
if (!mouseDown) return;
// 未达到 0.1s 长按阈值,不进入拖拽
if (!mouseDrag)
{
if (Time.realtimeSinceStartup - mouseDownTime < 0.1f)
return;
mouseDrag = true;
// 重新锁定起点,忽略等待期间的微小位移
startDragPosX = evt.mousePosition.x;
}
// 鼠标向右移了 50px → offsetFrame = RoundToInt(50 × 1000 / 100) = 500
float offsetPos = evt.mousePosition.x - startDragPosX;
int offsetFrame = Mathf.RoundToInt(offsetPos * LARGE_VALUE / frameUnitWidth);
if (isResizeRight)
{
// 右拉伸:endTime 从 5000 变成 5000+500 = 5500,startTime 不动
int targetEnd = Mathf.Max(startDragStartTime + 1, startDragEndTime + offsetFrame);
nodeData.ChangeTime(startDragStartTime, targetEnd);
}
else if (isResizeLeft)
{
// 左拉伸:startTime 从 2000 变成 2000+500 = 2500,endTime 不动
int targetStart = Mathf.Clamp(startDragStartTime + offsetFrame, 0, startDragEndTime - 1);
nodeData.ChangeTime(targetStart, startDragEndTime);
}
else
{
// 普通拖拽:整体平移,startTime=2500, endTime=2500+3000=5500
int targetStart = startDragStartTime + offsetFrame;
if (targetStart < 0) return;
nodeData.ChangeTime(targetStart, targetStart + startDragDuration);
}
// 片段超出当前总帧数时自动拓展边界
CheckFrameCount();
// 按新数据重算像素位置和宽度
ResetView(frameUnitWidth);
// 同步 Inspector 面板显示的开始/结束时间
Inspector.UpdateSelectedTrackItemTime(this, nodeData.StartTime, nodeData.EndTime);
}
/// <summary>
/// 鼠标松开:释放捕获,结束拖拽。
/// </summary>
private void OnMouseUp(MouseUpEvent evt)
{
if (mouseDrag) ApplyDrag();
mouseDown = false;
mouseDrag = false;
trackItemStyle.mainDragArea.ReleaseMouse();
}
边缘判定
取鼠标在元素内的本地 X 坐标,看是否落在左右 6px 内:
// TrackItem.cs
private const float RESIZE_EDGE_SIZE = 6f;
/// <summary>
/// 判断是否在右边缘 6px 内。
/// 比如元素宽 300px,localX=296 → 296 >= 300-6 → true
/// </summary>
private bool IsOnRightResizeEdge(MouseDownEvent evt)
{
float width = trackItemStyle.mainDragArea.layout.width;
return width > 0 && evt.localMousePosition.x >= width - RESIZE_EDGE_SIZE;
}
/// <summary>
/// 判断是否在左边缘 6px 内。
/// 比如 localX=4 → 4 <= 6 → true
/// </summary>
private bool IsOnLeftResizeEdge(MouseDownEvent evt)
{
float width = trackItemStyle.mainDragArea.layout.width;
return width > 0 && evt.localMousePosition.x <= RESIZE_EDGE_SIZE;
}
位置与宽度换算
数据层的时间单位是毫秒级(乘了 LARGE_VALUE=1000),换算到像素时要除回来:
片段像素位置 = startTime × frameUnitWidth / LARGE_VALUE
= 2000 × 100 / 1000 = 200px
片段像素宽度 = (endTime - startTime) / LARGE_VALUE × frameUnitWidth
= (5000 - 2000) / 1000 × 100 = 300px
拖拽/拉伸改的是 startTime 和 endTime,ResetView 用上面的公式重算像素值,片段在界面上就跟着动了。
6.6 节点扩展示例
前面运行时部分讲了 ActionNode 基类提供五个生命周期方法(OnCreate / OnPlay / OnUpdate / OnEnd / OnDestroy),扩展新节点就是继承它、重写需要的方法、在文件头用注释声明参数元数据。下面用三个最常见的节点来看实际写法——大部分节点只需要重写 OnPlay 和 OnEnd。
PlayAnimNode
播放动画,最基础也是用得最多的节点。OnPlay 时通知模型播指定动画,OnEnd 时带融合时间取消,避免硬切。
选中该节点后 Inspector 面板会显示动画相关的参数(animName、layerId、endTransitionTime 等),这些字段就是由文件头的 ---@field 注释自动生成的:

-- PlayAnimNode.lua
--- ActionNode custom param begin -- 编辑器解析这段注释生成 Inspector 参数面板
---@field animName string 动画名称 -- 对应 Animator 的 State 名
---@field layerId int 动画层级 -- Animator Layer 索引,0=Base 1=Upper ...
---@field endTransitionTime int 结束过渡时间 -- 单位毫秒,CancelAnim 时的融合时长
--- ActionNode custom param end
--- Chinese Name 播放动画 -- 编辑器轨道上显示的中文名
local ActionNode = require("ActionNode")
---@class PlayAnimNode : ActionNode
local PlayAnimNode = Class("PlayAnimNode", ActionNode)
----------------------------------------------------------------------
-- OnPlay:序列播放到该节点的 startTime 时触发
-- @param passTime number 跳入时间(单位毫秒),> 0 说明是从中途开始播
----------------------------------------------------------------------
function PlayAnimNode:OnPlay(passTime)
local model = self.sequence.modelContainer
local animName = self.data.param.animName
-- 外部可通过 extData 覆盖动画名,比如技能随机选一个攻击动画
if self.sequence.extData and self.sequence.extData.animName then
animName = self.sequence.extData.animName
end
-- 记住当前播放的 Layer,OnEnd 时用它取消
self.playLayerId = self.data.param.layerId
-- passTime > 0 时 PlayAnim 内部会跳到对应时间点,不会从头播
model:PlayAnim(self.playLayerId, animName, passTime)
end
----------------------------------------------------------------------
-- OnEnd:序列播放到该节点的 endTime 时触发
----------------------------------------------------------------------
function PlayAnimNode:OnEnd()
local model = self.sequence.modelContainer
if self.playLayerId then
-- 带融合时间取消动画,避免硬切回 idle
local transitionTime = self.data.param.endTransitionTime
model:CancelAnim(self.playLayerId, transitionTime)
self.playLayerId = nil
end
end
return PlayAnimNode
PlayEffectNode
播放特效。OnPlay 时按 effectId 查表拿到资源路径,创建特效实例并挂到指定骨骼上,支持局部偏移和缩放;OnEnd 时按 key 销毁。比动画节点多了”资源生命周期管理”和”骨骼绑定”两块逻辑。
Inspector 里可以配置特效 ID、绑定骨骼、偏移量、缩放等:

-- PlayEffectNode.lua
--- ActionNode custom param begin
---@field effectId int 特效资源ID -- 通过 EffectConfig 表查到实际路径
---@field bindBone string 绑定骨骼名称 -- 骨骼节点名,如 "Bip_RHand"
---@field bindOffset vector3 绑定偏移量 -- 相对骨骼的局部坐标偏移
---@field initScale vector3 初始缩放 -- 特效整体缩放,{0,0,0} 表示不改
--- ActionNode custom param end
--- Chinese Name 播放特效
local ActionNode = require("ActionNode")
local ModelUtils = require("ModelUtils")
---@class PlayEffectNode : ActionNode
local PlayEffectNode = Class("PlayEffectNode", ActionNode)
----------------------------------------------------------------------
-- OnPlay:创建特效 → 挂骨骼 → 设偏移/缩放 → 处理中途跳入
----------------------------------------------------------------------
function PlayEffectNode:OnPlay(passTime)
local model = self.sequence.modelContainer
local effectPath = EffectConfig[self.data.param.effectId].path
-- GetKey() 用节点序号生成唯一标识,OnEnd 销毁时靠它找回来
local key = self:GetKey()
-- 1. 创建特效实例,注册到 model 的资源列表(统一生命周期管理)
local effectObject = ModelUtils.CreateEffect(effectPath, key)
model:OwnObject(key, effectObject)
-- 2. 绑骨骼:把特效挂到指定骨骼节点下
local bindBone = self.data.param.bindBone
if bindBone and bindBone ~= "" then
model:BindObject(effectObject, bindBone)
-- 在骨骼坐标系下做局部偏移
local offset = self.data.param.bindOffset
if offset then
effectObject:SetLocalPosition(offset[1], offset[2], offset[3])
end
end
-- 3. 初始缩放(有些特效需要放大/缩小,scale 全 0 表示保持原始大小)
local scale = self.data.param.initScale
if scale and scale[1] ~= 0 then
effectObject:SetScale(scale[1], scale[2], scale[3])
end
-- 4. 中途跳入:粒子系统快进到 passTime 对应的状态
if passTime > 0 then
effectObject:Simulate(passTime)
end
end
----------------------------------------------------------------------
-- OnEnd:通过 key 找到并销毁特效实例
----------------------------------------------------------------------
function PlayEffectNode:OnEnd()
local model = self.sequence.modelContainer
model:DestroyOwnedObject(self:GetKey())
end
return PlayEffectNode
PlaySoundNode
播放音效。和前两个节点不同的是,OnEnd 不是无脑停止——一次性音效(比如”砍一刀”的音效)播完自己就停了,不需要干预;循环音效(比如持续燃烧的火焰声)或策划主动勾了”结束时停止”的才需要 OnEnd 里带淡出 Stop。
Inspector 里能配音效事件名、是否结束时停止、淡出时长:

-- PlaySoundNode.lua
--- ActionNode custom param begin
---@field soundEvent string 音效事件名 -- Wwise / FMOD 的 Event 名
---@field stopSoundAtEndTime bool 结束时停止音效 -- 勾上则 OnEnd 主动 Stop
---@field stopTransitionTime int 结束淡出时间(毫秒) -- Stop 时的淡出时长,默认 300ms
--- ActionNode custom param end
--- Chinese Name 播放音效
local ActionNode = require("ActionNode")
---@class PlaySoundNode : ActionNode
local PlaySoundNode = Class("PlaySoundNode", ActionNode)
----------------------------------------------------------------------
-- OnPlay:在模型根节点上播放 3D 音效
----------------------------------------------------------------------
function PlaySoundNode:OnPlay(passTime)
local model = self.sequence.modelContainer
local root = model:GetRootGameObject() -- 3D 音效的空间挂载点
local soundEvent = self.data.param.soundEvent
-- 记录音效时长,OnEnd 时用来判断是否需要主动 Stop
-- duration > 0 → 一次性音效,播完自动停
-- duration < 0 → 循环音效,不会自己停
self.eventDuration = AudioManager:GetEventLength(soundEvent)
self.playingId = AudioManager:PlaySound(soundEvent, root)
end
----------------------------------------------------------------------
-- OnEnd:按需停止音效
-- 一次性音效播完自己就停了,不用管;
-- 循环音效 或 策划勾了 stopSoundAtEndTime 的才主动带淡出停止
----------------------------------------------------------------------
function PlaySoundNode:OnEnd()
local stopAtEnd = self.data.param.stopSoundAtEndTime
if self.playingId and (self.eventDuration < 0 or stopAtEnd) then
local fadeOut = self.data.param.stopTransitionTime or 300 -- 默认 300ms 淡出
AudioManager:StopSound(self.playingId, fadeOut)
end
-- 清理引用,避免序列复用时残留
self.playingId = nil
self.eventDuration = nil
end
return PlaySoundNode
小结
三个节点的模式一致:OnPlay 做事、OnEnd 撤销,差别只在具体做什么。项目里实际跑着 80 多种节点——相机控制、贝塞尔曲线移动、UI 显示、描边、透明度、模型缩放、子序列嵌套、暂停等待等等,全都是继承 ActionNode、重写生命周期方法、在文件头声明参数元数据。加一个新节点只需要新建一个 Lua 文件,不动已有代码,编辑器那边也会自动解析出参数面板。
6.7 和 Timeline 方案的对比
做完了自然要和 Timeline 方案对比一下。
架构对比
graph LR
subgraph timeline["Timeline 过场编辑器"]
T1["TimelineAsset
.playable 文件"]
T2["PlayableDirector"]
T3["PlayableGraph
权重求值框架"]
T4["TrackAsset 四件套
Track+Asset+Behaviour+Mixer"]
end
subgraph custom["自研表现编辑器"]
C1["纯文本配置
Lua table / JSON / ..."]
C2["ActionSequencePlayer"]
C3["ActionSequence
线性遍历 + 时间比较"]
C4["ActionNode 子类
一个文件一种节点"]
end
T1 -->|"Director 加载"| T2 -->|"建图求值"| T3 -->|"每帧 ProcessFrame"| T4
C1 -->|"加载配置"| C2 -->|"创建序列"| C3 -->|"遍历节点触发回调"| C4
选型建议
用 Timeline:
- 需要动画混合(CrossFade、分层混合、权重过渡)
- 需要利用 Timeline 窗口的可视化编排能力(Cinemachine 轨道、AnimationTrack 的曲线编辑)
- 过场演出、CG、剧情动画——内容结构相对固定,轨道类型不多,但每条轨道行为复杂
- 团队里有 TA 或动画师直接在 Timeline 窗口里工作
用自研编辑器:
- 不需要混合,节点之间是纯并行关系
- 需要热更新数据(纯文本配置)
- 节点类型非常多(几十种),但每种逻辑简单
- 运行时需要同一对象上并发播放多个序列,并且有层级打断需求
- 技能表现、交互动作、UI 动效序列——内容结构灵活、数量庞大、迭代频繁
两者不互斥。自研编辑器里有 PlayCutsceneNode 可以触发 Timeline 过场,Timeline 过场编辑器里也可以通过自定义轨道触发表现序列。
开发成本
从零开始做的话,自研编辑器的开发成本比基于 Timeline 做过场编辑器低不少。过场编辑器之所以复杂,是因为要处理 Timeline 的 Binding、Prefab 序列化、编辑器预览时的 Director 时间推进、SubTimeline 嵌套等问题(第 5 篇花了 4000 多行讲的就是这些)。自研编辑器从头到尾都是自己控制的——数据怎么存自己定、运行时怎么跑自己写、编辑器界面用 UIToolkit 搭,不用和 Timeline / Playable 的既有机制做对接。
当然反过来说,自研编辑器也不具备 Timeline 那些高级能力(动画混合、曲线编辑、Graph 可视化调试)。真需要这些的话,还是老老实实用 Timeline。
6.8 总结
整套东西拆开看就三块:数据是纯文本配置,运行时靠一个 Update 循环按时间驱动节点,编辑器用 UIToolkit 画了一个类 Timeline 的时间轴界面。加新节点只需要写一个 Lua 文件,继承 ActionNode 重写几个方法,声明参数元数据,编辑器那边自动就能识别。
不需要 PlayableGraph,不需要 IntervalTree,不需要 Mixer 权重归一化——技能表现场景根本用不到这些东西,去掉之后系统轻量、维护省心。项目里 80 多种节点类型、600 多个配置文件跑在这套系统上,到目前没出过架构层面的问题。
回头看这个系列——Playable 底层求值框架、Timeline 轨道体系和源码、基于 Timeline 搭过场编辑器、绕开 Timeline 自己做一套——算是把”用 Unity 做时间轴驱动内容”的几条路都走了一遍。具体选哪条看项目需求,该用 Timeline 就用 Timeline,该自己写就自己写,没有银弹。
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 785293209@qq.com