BattlePlayable战斗时间轴
本文最后更新于 2026年7月10日 下午
BattlePlayable 战斗时间轴
BattlePlayable 是什么
BattlePlayable 把攻击、技能和战斗表现拆成可以编辑、保存、预览和回放的时间轴。数据链路:
1 | |
编辑器保存出 BattleData,运行时只处理 BattleData 和一组 TrackPlayer。

编辑器会打开独立的 PreviewSceneStage,加载场景和角色,拖动时间轴或播放时采样各条 Track。

左边是 Track,右边是 Clip。比如 Transition、Audio、Event、GameObject 都是独立轨道。每个 Clip 只描述自己在时间轴上的位置和参数。
BattleData
BattleData 是最终保存的运行时数据,使用 MemoryPack 序列化。
顶层结构大概是:
1 | |
每种 Track 都有自己的 List,没有统一的 List<BaseTrackData>。运行时不用判断基类类型,但新增 Track 或 Clip 时要补数据、编辑器转换和播放器。
比较关键的 Track:
| Track | 作用 |
|---|---|
TransitionTrackData |
播放角色动画,或者指定目标 GameObject 的动画 |
TriggerTrackData |
描述命中范围,比如 Box、Sphere、Pie、SequenceBox、TriggerOwner |
DamageTrackData |
描述伤害、蓄力伤害、传统 Buff、ECS Buff、受击镜头效果 |
EventTrackData |
按帧触发一次事件 |
AudioTrackData |
播放音效、受击音效、FMOD 全局参数 |
GameObjectTrackData |
生成和销毁 GameObject |
CommonDataTrackData |
放通用事件数据、通用 Buff 数据、通用 ECS Buff 数据 |
SceneConstructTrackData |
服务器侧用的场景构造动画数据 |
editorIndex 用来在重新导入时恢复轨道顺序。保存后 Track 已经拆到不同 List,不能再靠数组位置还原。
Clip 的时间
大部分 Clip 用 startTime + duration,运行时有进入、更新、退出三个阶段。下面这些 Clip 只按帧触发一次:
EventClipDataSpawnGameObjectClipData
它们记录的是 frame,运行时通过 frame / frameRate 转成秒。
持续表现用秒和 duration,一次性事件用帧。编辑器可以切换显示方式,frameRate 默认 30,并保存到 BattleData。
编辑器配置流程
入口是:
1 | |
基本流程是:
- 选择预览场景。
- 选择角色模型。
- 在时间轴里添加 Track。
- 在 Track 里拖资源或者右键添加 Clip。
- 调整 Clip 的开始时间、持续时间和参数。
- 打开预览,在 Scene 里看效果。
- 保存为
.bytes。
BattleTrackRegistry 反射所有非抽象 BattleTrack 子类,再生成“添加轨道”菜单。Clip 的 Inspector 也不是直接编辑运行时数据。
编辑器里每种 Track 都有自己的编辑器 Clip 类型。比如 TransitionAssetClip、BoxClip、DamageClip、AudioClip。保存时再由 BattleDataConverter 转成运行时的 TransitionClipData、BoxTriggerClipData、DamageClipData、AudioClipData。
编辑器 Clip 可以引用 Unity 资源,运行时 Clip 只保存路径、GUID、枚举、数值和烘焙数据。
预览流程
BattlePreviewStage 的流程:
- 打开一个
PreviewSceneStage。 - 加载
BattleEditor.unity作为预览场景。 - 实例化角色 prefab。
- 查找或者补一个
AnimancerComponent。 - 时间轴播放时调用每条
BattleTrack.SampleAtTime。
预览走编辑器侧的 BattleTrack.SampleAtTime,不会直接跑 BattlePlayer。保存时才通过 BattleDataConverter.Export 转成运行时数据。
保存和加载
1 | |
然后写到几个目录:
| 目录 | 作用 |
|---|---|
Assets/BundleResources/Character/battle/ |
客户端默认保存目录 |
../Configs/generated/character/battle/ |
生成配置目录 |
Config/generated/battle/ |
当前工程内的配置目录 |
Config/generated/battle/backup/ |
JSON 备份目录 |
bytes 给运行时加载,JSON 用于冲突处理和恢复。
SceneConstructTrack 会把 Unity 的 AnimationClip 烘焙成 BattlePlayable.AnimationClip,写到:
1 | |
服务器没有 Unity 场景和 Transform,需要使用烘焙后的曲线。
Git 协作
.bytes 不适合处理 Git 冲突,所以保存时会额外写一份 JSON:
1 | |
冲突处理:
- 先解决 JSON 冲突。
- 确认 Track、Clip、时间、资源路径都符合预期。
- 在编辑器里执行“恢复备份数据”。
- 工具会读取 JSON,反序列化成
BattleData。 - 再走一遍
BattleDataConverter.Import -> Export。 - 最后重新写出
MemoryPack bytes。
恢复会重新跑 Import/Export。SceneConstructTrack 也会重新烘焙动画,再写 Config/generated/anim 和 Config/generated/battle。
注意不要只修 JSON,不重新生成 bytes。否则本地看 JSON 是对的,运行时加载的还是旧二进制。
运行时播放
运行时入口是 BattlePlayer。
Play 时会把 BattleData 里的各类 Track 分配给对应的 TrackPlayer:
1 | |
分配完成后计算整段长度。Tick:
1 | |
每个具体的 TrackPlayer 只负责自己的 Track。
例如 DamageTrackPlayer 会遍历:
DamageClipDataChargeDamageClipDataBuffClipDataECSBuffClipDataHitCameraEffectClipData
TriggerTrackPlayer 会遍历:
BoxTriggerClipDataSequenceBoxTriggerClipDataSphereTriggerClipDataPieTriggerClipDataTriggerOwnerClipData
真正的生命周期逻辑在 TrackPlayer.ProcessDurationClip 里。
大概逻辑是:
1 | |
业务代码不再判断 Clip 是否刚进入或退出,只注册消息。
MessageCenter
BattleMessageCenter 是运行时的派发中心。
它的 key 是:
1 | |
注册时写法类似:
1 | |
TrackPlayer 只负责发:
1 | |
BattlePlayable 只派发 Clip 的进入、更新和退出,不处理命中后的伤害逻辑。
客户端接入
客户端侧主要是 BattleAttackView 在消费 BattlePlayable。
它启动时会:
- 根据
Data.FileName找到.bytes。 - 用
MemoryPackSerializer.Deserialize<BattleData>反序列化。 - 调用
battlePlayer.Play(battleData)。 - 注册各类
BattleMessageCenter回调。
当前接入的消息包括:
GameObjectTrackData + SpawnAndDespawnGameObjectClipDataAudioTrackData + AudioClipDataAudioTrackData + HitAudioDataClipDataAudioTrackData + FmodGlobalParameterClipDataDamageTrackData + HitCameraEffectClipDataTriggerTrackData + BoxTriggerClipDataCommonDataTrackData + CommonDataClipDataCommonDataTrackData + CommonBuffDataClipDataEventTrackData + EventClipDataTransitionTrackData + TransitionClipData
特效、FMOD、BoxTrigger 检测和怪物动画都由 BattleAttackView 处理。
服务器对时和快进
BattleAttackView.OnUpdate 每帧会先同步服务器侧状态:
1 | |
然后才根据模式推进 BattlePlayer。
普通模式下,对时逻辑是:
1 | |
如果 deltaSeconds > 0,就快进 BattlePlayer。
快进不是一次性 Tick 一个很大的 delta,而是按 frameRate 拆成小步:
1 | |
小步快进可以避免一次跨过太多 Trigger 检测。
GameLogicConnection.OnServerTimeSynced 会把服务器时间写到 TimeUtil.SetBattleServerTimestamp。所以这里拿到的 GetBattleServerTimestampMs 是已经校准过的战斗服务器时间。
ManualTick
还有一种模式是 Data.ManualTick。
这种模式不通过 LastPlayTimeStamp 推算播放时间,而是直接跟服务器下发的 Data.CurrentTime。
逻辑大概是:
1 | |
如果 targetTime < battlePlayer.time,说明服务器时间回退了。
这时客户端会先 ReplayBattlePlayer(),把播放器重置,再从头 Tick 到目标时间。
Replay 和 Jump
服务器会通过两个结构控制客户端播放:
ReplayInfo.InvokeTimestampJumpInfo.InvokeTimestamp
客户端会记录 _appliedReplayTimestamp 和 _appliedJumpTimestamp,同一个 invoke timestamp 只处理一次。
Replay 很直接:
1 | |
Jump 有两种情况:
JumpStartTime == JumpTargetTime:只解除阻塞,不真的跳。- 否则调用
battlePlayer.Jump(JumpTargetTime)。
执行服务器 Jump 时会设置 _isApplyingServerJump,避免重新采样到 EventClipType.跳过判断点 后再次阻塞。
跳过判断点
EventTrack 里有一些特殊事件,比如:
EventClipType.跳过判断点EventClipType.从头循环播放
客户端播放到这些事件时,会设置:
1 | |
之后本地普通 Tick 会暂停推进,等待服务器推送 Replay 或 Jump。
遇到需要服务器判定的分支时,本地先停住,等待服务器结果。
焦点变化
MagicMirrorBattleAttackView 监听了:
1 | |
当 Unity 重新获得焦点时,会调用:
1 | |
普通模式下,它会:
- 先同步
LastPlayTimeStamp、ReplayInfo、JumpInfo。 - 再
FastForwardToBattleServerTime()。 - 设置
_skipNextUpdateTickAfterFocus = true。
_skipNextUpdateTickAfterFocus 避免恢复焦点后的下一帧又按本地 dt 多 Tick 一次。
ManualTick 模式下则直接根据 CurrentTime 补 Tick,并同步 BoxTrigger。
SceneConstruct
服务器没有真实场景和 Transform,所以 SceneConstruct 自己维护一棵节点树:
1 | |
每个节点保存:
localPositionlocalRotationlocalScaleworldPositionworldRotation
AnimationClipSampler 会把烘焙后的动画曲线采样出来,写到对应 BattleNode 的 local TRS 上,然后 BattleScene.UpdateWorldTransforms 从 root 开始更新世界坐标。
它只给命中范围、挂点和路径提供位置、旋转,不负责渲染。
新增 Clip 的流程
以新增一个 TriggerOwnerClip 这类无额外参数的 Clip 为例:
- 在
BattleData.cs里新增运行时ClipData。 - 在对应
TrackData里新增 List。 - 在对应
TrackPlayer里新增 active 集合,并调用ProcessDurationClip。 - 在编辑器 Track 文件里新增 Editor Clip 类型。
- 在右键菜单或拖拽逻辑里支持创建它。
- 在
BattleDataConverter.Export里把 Editor Clip 转成 Runtime ClipData。 - 在
BattleDataConverter.Import里把 Runtime ClipData 还原成 Editor Clip。 - 如果运行时需要业务效果,再注册对应的
BattleMessageCenter回调。
最容易漏的是导入导出:只加编辑器 Clip,保存后没有数据;只加运行时数据,编辑器里又没法配置。TrackPlayer 能 Tick 也不代表业务生效,还要注册消息。
注意点
TrackPlayer 的顺序不是纯展示问题。
比如 Trigger 的业务回调如果要读取当前 active 的 Damage 或 Buff 数据,那么 DamageTrackPlayer 就需要在 TriggerTrackPlayer 前面 Tick。否则同一帧里 Trigger 已经触发,但 Damage/Buff 还没进入 active 集合,就会读不到。
EventClipData 和 SpawnGameObjectClipData 是一次性触发,不是 duration clip。它们用 frame,不是 startTime + duration。
编辑器预览和运行时播放是两套入口。预览走 BattleTrack.SampleAtTime,运行时走 BattlePlayer.Tick。
运行时数据不要保存 Unity 对象引用。需要保存资源时,导出成 asset path、GUID、文件名或者烘焙后的数据。
editorIndex 看起来只是编辑器字段,但不要随便删。它负责导入后恢复轨道顺序。
新增 Track 或 Clip 时,最好顺着“数据结构 -> 编辑器 -> Converter -> TrackPlayer -> MessageCenter 回调”这条链路检查一遍。
参考代码
Assets/Jinn/BattlePlayable/BattleData.csAssets/Jinn/BattlePlayable/BattlePlayer.csAssets/Jinn/BattlePlayable/TrackPlayer.csAssets/Jinn/BattlePlayable/BattleMessageCenter.csAssets/Jinn/BattlePlayable/Editor/BattleDataEditor.csAssets/Jinn/BattlePlayable/Editor/BattleDataConverter.csAssets/Jinn/BattlePlayable/Editor/BattlePreviewStage.csAssets/Jinn/GamePlay/Runtime/BattlePlayable.Jinn/BattleAttackView.csAssets/Jinn/GamePlay/Runtime/BattlePlayable.Jinn/MagicMirrorBattleAttackView.csAssets/Jinn/RemoteDataCenter/Runtime/GameConnects/GameLogicConnection.csAssets/Jinn/GamePlay/Runtime/Model/BattleAttackModelData.cs
