@@ -0,0 +1,956 @@
#if UNITY_EDITOR
using System ;
using System.Collections.Generic ;
using System.Linq ;
using System.Reflection ;
using UnityEngine ;
using UnityEditor ;
using Sirenix.OdinInspector ;
using Sirenix.OdinInspector.Editor ;
using Sirenix.Utilities.Editor ;
using UnityEditor.Callbacks ;
namespace SLSUtilities.Feedback.Editor
{
public class FeedbackDataEditorWindow : OdinEditorWindow
{
// ─────────────── 常量 ───────────────
private const float RULER_HEIGHT = 22f ;
private const float LANE_HEIGHT = 24f ;
private const float TRACK_PADDING = 2f ;
private const float TRACK_LABEL_WIDTH = 140f ;
private const float EXPAND_BUTTON_SIZE = 14f ;
private const float DRAG_HANDLE_WIDTH = 6f ;
private const float MIN_TIMELINE_DURATION = 1.0f ;
private const float TIMELINE_PADDING_RATIO = 0.15f ;
private const float DEFAULT_SNAP_INTERVAL = 0.05f ;
private const float MIN_CLIP_DURATION = 0.01f ;
// ─────────────── 序列化字段 ───────────────
[Title("Feedback Data Editor")]
[ShowInInspector, AssetsOnly, PropertyOrder(-100)]
[LabelText("Target Data")]
[OnValueChanged("OnDataChanged")]
public FeedbackData targetData ;
[ShowInInspector, PropertyOrder(-99)]
[HorizontalGroup("TimelineSettings", Width = 200)]
[LabelText("View Duration")]
[LabelWidth(90)]
[MinValue(0.1f)]
[OnValueChanged("OnViewDurationChanged")]
public float viewDuration = MIN_TIMELINE_DURATION ;
[ShowInInspector, PropertyOrder(-99)]
[HorizontalGroup("TimelineSettings", Width = 160)]
[LabelText("Snap")]
[LabelWidth(35)]
[MinValue(0.001f)]
public float snapInterval = DEFAULT_SNAP_INTERVAL ;
[ShowInInspector, PropertyOrder(-99)]
[HorizontalGroup("TimelineSettings", Width = 120)]
[LabelText("Auto Fit")]
[LabelWidth(55)]
public bool autoFitDuration = true ;
[ShowInInspector, PropertyOrder(1)]
[ShowIf("targetData")]
[InlineEditor(Expanded = true, ObjectFieldMode = InlineEditorObjectFieldModes.Hidden)]
public FeedbackData dataEditor ;
// ─────────────── 拖拽状态 ───────────────
private enum DragMode { None , ClipMove , ClipLeft , ClipRight }
private DragMode _currentDragMode = DragMode . None ;
private int _dragTrackIndex = - 1 ;
private int _dragClipIndex = - 1 ;
private float _dragStartMouseTime ;
private float _dragStartClipStart ;
private float _dragStartClipDuration ;
private bool _isDirty ;
// ─────────────── 选中状态 ───────────────
private int _selectedTrackIndex = - 1 ;
private int _selectedClipIndex = - 1 ;
// ─────────────── 展开状态与布局缓存 ───────────────
private readonly HashSet < int > _expandedTracks = new HashSet < int > ( ) ;
private struct TrackLayout
{
public float yOffset ;
public float totalHeight ;
public int laneCount ; // 当前显示的 lane 数(折叠时为 1)
public int naturalLaneCount ; // 实际需要的 lane 数(用于决定是否显示展开按钮)
public int [ ] clipLanes ;
}
private TrackLayout [ ] _trackLayouts ;
// ─────────────── 颜色 ───────────────
private static readonly Dictionary < Type , Color > ActionColorCache = new Dictionary < Type , Color > ( ) ;
private static readonly Color BackgroundColor = new Color ( 0.16f , 0.16f , 0.16f ) ;
private static readonly Color TrackBackgroundColor = new Color ( 0.22f , 0.22f , 0.22f ) ;
private static readonly Color TrackAltBackgroundColor = new Color ( 0.20f , 0.20f , 0.20f ) ;
private static readonly Color RulerBackgroundColor = new Color ( 0.14f , 0.14f , 0.14f ) ;
private static readonly Color SelectionOutlineColor = new Color ( 1f , 0.85f , 0.2f ) ;
private static readonly Color MutedOverlayColor = new Color ( 0.5f , 0.5f , 0.5f , 0.4f ) ;
private static readonly Color SoloIndicatorColor = new Color ( 1f , 0.85f , 0.2f ) ;
private static readonly Color DefaultClipColor = new Color ( 0.5f , 0.6f , 0.7f , 0.8f ) ;
private static readonly Color ExpandButtonColor = new Color ( 0.6f , 0.6f , 0.6f ) ;
private static readonly Color LaneSeparatorColor = new Color ( 0.3f , 0.3f , 0.3f , 0.3f ) ;
// ─────────────── Label 宽度 ───────────────
private const float INSPECTOR_LABEL_WIDTH = 155f ;
/// <summary>
/// 包装每个 Editor 的绘制,使用 Odin 的 GUIHelper 栈式 Label 宽度,
/// 确保 InlineEditor 内部也能生效。
/// </summary>
protected override void DrawEditor ( int index )
{
GUIHelper . PushLabelWidth ( INSPECTOR_LABEL_WIDTH ) ;
base . DrawEditor ( index ) ;
GUIHelper . PopLabelWidth ( ) ;
}
// ─────────────── 窗口入口 ───────────────
/// <summary>
/// 通过菜单打开窗口。
/// </summary>
[MenuItem("Tools/SLS Utilities/Feedback Data Editor")]
private static void OpenWindow ( )
{
var window = GetWindow < FeedbackDataEditorWindow > ( ) ;
window . titleContent = new GUIContent ( "Feedback Editor" ) ;
window . Show ( ) ;
}
/// <summary>
/// 双击 FeedbackData 资产时自动打开编辑器。
/// </summary>
[OnOpenAsset(1)]
public static bool OnOpenAsset ( int instanceID , int line )
{
FeedbackData data = EditorUtility . EntityIdToObject ( instanceID ) as FeedbackData ;
if ( data = = null ) return false ;
OpenWindow ( ) ;
var window = GetWindow < FeedbackDataEditorWindow > ( ) ;
window . targetData = data ;
window . OnDataChanged ( ) ;
return true ;
}
// ─────────────── 数据变更回调 ───────────────
private void OnDataChanged ( )
{
dataEditor = targetData ;
if ( autoFitDuration ) FitViewDuration ( ) ;
_selectedTrackIndex = - 1 ;
_selectedClipIndex = - 1 ;
_expandedTracks . Clear ( ) ;
}
private void OnViewDurationChanged ( )
{
autoFitDuration = false ;
}
/// <summary>
/// 自动适配 viewDuration 到数据实际长度。
/// </summary>
private void FitViewDuration ( )
{
if ( targetData = = null ) return ;
float total = targetData . TotalDuration ;
viewDuration = Mathf . Max ( total * ( 1f + TIMELINE_PADDING_RATIO ) , MIN_TIMELINE_DURATION ) ;
}
// ═══════════════════════════════════════════
// 轨道布局计算
// ═══════════════════════════════════════════
/// <summary>
/// 重新计算所有轨道的布局( lane 分配、高度、Y 偏移)。
/// </summary>
private void ComputeTrackLayouts ( )
{
if ( targetData ? . tracks = = null | | targetData . tracks . Count = = 0 )
{
_trackLayouts = Array . Empty < TrackLayout > ( ) ;
return ;
}
int numTracks = targetData . tracks . Count ;
_trackLayouts = new TrackLayout [ numTracks ] ;
float currentY = 0 ;
for ( int i = 0 ; i < numTracks ; i + + )
{
FeedbackTrack track = targetData . tracks [ i ] ;
bool isExpanded = _expandedTracks . Contains ( i ) ;
int [ ] clipLanes = AssignClipLanes ( track ) ;
// 始终计算实际所需 lane 数,用于决定是否显示展开按钮
int naturalLaneCount = ( clipLanes ! = null & & clipLanes . Length > 0 )
? clipLanes . Max ( ) + 1
: 1 ;
// 显示的 lane 数:折叠时固定为 1, 展开时用实际值
int displayLaneCount = isExpanded ? naturalLaneCount : 1 ;
float height = displayLaneCount * LANE_HEIGHT + TRACK_PADDING * 2 ;
_trackLayouts [ i ] = new TrackLayout
{
yOffset = currentY ,
totalHeight = height ,
laneCount = displayLaneCount ,
naturalLaneCount = naturalLaneCount ,
clipLanes = clipLanes
} ;
currentY + = height ;
}
}
/// <summary>
/// 为一条轨道中的 Clips 分配 lane, 使重叠的 Clip 位于不同 lane。
/// 贪心算法:按 startTime 排序,依次放入第一个无冲突的 lane。
/// </summary>
private int [ ] AssignClipLanes ( FeedbackTrack track )
{
if ( track ? . clips = = null | | track . clips . Count = = 0 ) return Array . Empty < int > ( ) ;
int clipCount = track . clips . Count ;
int [ ] lanes = new int [ clipCount ] ;
int [ ] sortedIndices = Enumerable . Range ( 0 , clipCount )
. OrderBy ( idx = > track . clips [ idx ] ? . startTime ? ? 0 )
. ToArray ( ) ;
List < float > laneEndTimes = new List < float > ( ) ;
foreach ( int idx in sortedIndices )
{
FeedbackClip clip = track . clips [ idx ] ;
if ( clip = = null ) { lanes [ idx ] = 0 ; continue ; }
float clipStart = clip . startTime ;
int assignedLane = - 1 ;
for ( int lane = 0 ; lane < laneEndTimes . Count ; lane + + )
{
if ( clipStart > = laneEndTimes [ lane ] )
{
assignedLane = lane ;
break ;
}
}
if ( assignedLane = = - 1 )
{
assignedLane = laneEndTimes . Count ;
laneEndTimes . Add ( 0 ) ;
}
lanes [ idx ] = assignedLane ;
laneEndTimes [ assignedLane ] = clip . EndTime ;
}
return lanes ;
}
private float GetTotalTracksHeight ( )
{
if ( _trackLayouts = = null | | _trackLayouts . Length = = 0 )
return LANE_HEIGHT + TRACK_PADDING * 2 ;
var last = _trackLayouts [ ^ 1 ] ;
return last . yOffset + last . totalHeight ;
}
// ═══════════════════════════════════════════
// 时间轴 GUI 主入口
// ═══════════════════════════════════════════
[OnInspectorGUI]
[PropertyOrder(-98)]
[ShowIf("targetData")]
private void DrawTimelineGUI ( )
{
if ( targetData = = null ) return ;
if ( autoFitDuration ) FitViewDuration ( ) ;
ComputeTrackLayouts ( ) ;
float tracksHeight = GetTotalTracksHeight ( ) ;
float totalHeight = RULER_HEIGHT + tracksHeight ;
Rect totalArea = GUILayoutUtility . GetRect ( GUIContent . none , GUIStyle . none , GUILayout . Height ( totalHeight ) ) ;
Rect labelHeaderArea = new Rect ( totalArea . x , totalArea . y , TRACK_LABEL_WIDTH , RULER_HEIGHT ) ;
Rect rulerArea = new Rect (
totalArea . x + TRACK_LABEL_WIDTH ,
totalArea . y ,
totalArea . width - TRACK_LABEL_WIDTH ,
RULER_HEIGHT
) ;
Rect allTracksArea = new Rect (
totalArea . x ,
totalArea . y + RULER_HEIGHT ,
totalArea . width ,
tracksHeight
) ;
EditorGUI . DrawRect ( totalArea , BackgroundColor ) ;
EditorGUI . DrawRect ( labelHeaderArea , RulerBackgroundColor ) ;
EditorGUI . DrawRect ( rulerArea , RulerBackgroundColor ) ;
DrawRuler ( rulerArea , viewDuration ) ;
DrawTracks ( allTracksArea , viewDuration ) ;
DrawTotalDurationLine ( rulerArea , allTracksArea , viewDuration ) ;
HandleMouseInput ( totalArea , rulerArea , allTracksArea , viewDuration ) ;
if ( _isDirty )
{
Repaint ( ) ;
_isDirty = false ;
}
GUILayout . Space ( 8 ) ;
}
// ─────────────── 标尺绘制 ───────────────
private void DrawRuler ( Rect rulerArea , float duration )
{
if ( duration < = 0 ) return ;
// 自适应刻度间距
float tickInterval = CalculateTickInterval ( duration , rulerArea . width ) ;
Handles . color = new Color ( 0.5f , 0.5f , 0.5f , 0.5f ) ;
GUIStyle tickLabelStyle = new GUIStyle ( EditorStyles . miniLabel )
{
normal = { textColor = new Color ( 0.7f , 0.7f , 0.7f ) } ,
alignment = TextAnchor . UpperLeft
} ;
int numTicks = Mathf . FloorToInt ( duration / tickInterval ) ;
for ( int i = 0 ; i < = numTicks ; i + + )
{
float time = i * tickInterval ;
float xPos = rulerArea . x + ( time / duration ) * rulerArea . width ;
bool isMajor = Mathf . Approximately ( time % ( tickInterval * 2f ) , 0f ) | | i = = 0 ;
float lineHeight = isMajor ? rulerArea . height : rulerArea . height * 0.5f ;
Handles . DrawLine (
new Vector3 ( xPos , rulerArea . yMax - lineHeight ) ,
new Vector3 ( xPos , rulerArea . yMax )
) ;
if ( isMajor )
{
GUI . Label ( new Rect ( xPos + 2 , rulerArea . y , 50 , rulerArea . height ) , $"{time:F2}s" , tickLabelStyle ) ;
}
}
// 总时长标记
GUIStyle totalStyle = new GUIStyle ( EditorStyles . miniLabel )
{
normal = { textColor = new Color ( 0.9f , 0.9f , 0.5f ) } ,
alignment = TextAnchor . UpperRight
} ;
string totalLabel = $"Total: {targetData.TotalDuration:F2}s" ;
GUI . Label ( new Rect ( rulerArea . xMax - 100 , rulerArea . y , 98 , rulerArea . height ) , totalLabel , totalStyle ) ;
}
/// <summary>
/// 根据时间轴总时长和像素宽度计算合理的刻度间距。
/// </summary>
private float CalculateTickInterval ( float duration , float width )
{
float targetPixelsPerTick = 60f ;
float idealInterval = duration * targetPixelsPerTick / width ;
float [ ] candidates = { 0.01f , 0.02f , 0.05f , 0.1f , 0.2f , 0.25f , 0.5f , 1f , 2f , 5f , 10f } ;
foreach ( float c in candidates )
{
if ( c > = idealInterval ) return c ;
}
return 10f ;
}
// ─────────────── TotalDuration 指示线 ───────────────
private void DrawTotalDurationLine ( Rect rulerArea , Rect tracksArea , float duration )
{
float total = targetData . TotalDuration ;
if ( total < = 0 | | duration < = 0 ) return ;
float xPos = rulerArea . x + ( total / duration ) * rulerArea . width ;
Handles . color = new Color ( 0.9f , 0.9f , 0.3f , 0.4f ) ;
Handles . DrawLine (
new Vector3 ( xPos , rulerArea . y ) ,
new Vector3 ( xPos , tracksArea . yMax )
) ;
}
// ─────────────── 轨道绘制 ───────────────
private void DrawTracks ( Rect allTracksArea , float duration )
{
if ( targetData . tracks = = null | | targetData . tracks . Count = = 0 )
{
GUI . Label ( allTracksArea , "No tracks. Add tracks in the inspector below." ,
EditorStyles . centeredGreyMiniLabel ) ;
return ;
}
for ( int trackIdx = 0 ; trackIdx < targetData . tracks . Count ; trackIdx + + )
{
if ( trackIdx > = _trackLayouts . Length ) break ;
FeedbackTrack track = targetData . tracks [ trackIdx ] ;
ref TrackLayout layout = ref _trackLayouts [ trackIdx ] ;
bool isExpanded = _expandedTracks . Contains ( trackIdx ) ;
float trackY = allTracksArea . y + layout . yOffset ;
Rect labelRect = new Rect ( allTracksArea . x , trackY , TRACK_LABEL_WIDTH , layout . totalHeight ) ;
Rect contentRect = new Rect (
allTracksArea . x + TRACK_LABEL_WIDTH ,
trackY ,
allTracksArea . width - TRACK_LABEL_WIDTH ,
layout . totalHeight
) ;
// 轨道背景
Color bgColor = ( trackIdx % 2 = = 0 ) ? TrackBackgroundColor : TrackAltBackgroundColor ;
EditorGUI . DrawRect ( contentRect , bgColor ) ;
// 展开时绘制 lane 分隔线
if ( isExpanded & & layout . laneCount > 1 )
{
Handles . color = LaneSeparatorColor ;
for ( int lane = 1 ; lane < layout . laneCount ; lane + + )
{
float lineY = trackY + TRACK_PADDING + lane * LANE_HEIGHT ;
Handles . DrawLine (
new Vector3 ( contentRect . x , lineY ) ,
new Vector3 ( contentRect . xMax , lineY )
) ;
}
}
// 轨道标签
DrawTrackLabel ( labelRect , track , trackIdx , isExpanded , layout . laneCount ) ;
// Clips
if ( track . clips ! = null )
{
for ( int clipIdx = 0 ; clipIdx < track . clips . Count ; clipIdx + + )
{
int lane = 0 ;
if ( isExpanded & & layout . clipLanes ! = null & & clipIdx < layout . clipLanes . Length )
lane = layout . clipLanes [ clipIdx ] ;
DrawClip ( contentRect , track . clips [ clipIdx ] , trackIdx , clipIdx , duration , lane , isExpanded ) ;
}
}
// Mute 叠加层
if ( track . mute )
{
EditorGUI . DrawRect ( contentRect , MutedOverlayColor ) ;
}
}
}
/// <summary>
/// 绘制轨道标签区域(名称 + mute/solo 指示 + 展开按钮)。
/// </summary>
private void DrawTrackLabel ( Rect labelRect , FeedbackTrack track , int trackIdx , bool isExpanded , int laneCount )
{
EditorGUI . DrawRect ( labelRect , RulerBackgroundColor ) ;
if ( track . solo )
{
EditorGUI . DrawRect ( new Rect ( labelRect . x , labelRect . y , 3f , labelRect . height ) , SoloIndicatorColor ) ;
}
string prefix = track . mute ? "[M] " : track . solo ? "[S] " : "" ;
string displayName = string . IsNullOrEmpty ( track . trackName ) ? $"Track {trackIdx}" : track . trackName ;
Color textColor = track . mute ? new Color ( 0.5f , 0.5f , 0.5f ) : new Color ( 0.85f , 0.85f , 0.85f ) ;
GUIStyle labelStyle = new GUIStyle ( EditorStyles . label )
{
fontSize = 11 ,
padding = new RectOffset ( 8 , 0 , 0 , 0 ) ,
normal = { textColor = textColor }
} ;
float firstLaneH = LANE_HEIGHT + TRACK_PADDING * 2 ;
Rect nameLabelRect = new Rect ( labelRect . x , labelRect . y , labelRect . width - EXPAND_BUTTON_SIZE - 6f , firstLaneH ) ;
EditorGUI . LabelField ( nameLabelRect , $"{prefix}{displayName}" , labelStyle ) ;
// 使用 naturalLaneCount 判断是否需要显示展开按钮
ref TrackLayout layout = ref _trackLayouts [ trackIdx ] ;
if ( layout . naturalLaneCount > 1 | | isExpanded )
{
DrawExpandToggle ( labelRect , trackIdx , isExpanded , layout . naturalLaneCount ) ;
}
}
/// <summary>
/// 绘制展开/折叠三角按钮。
/// </summary>
private void DrawExpandToggle ( Rect labelRect , int trackIdx , bool isExpanded , int laneCount )
{
float firstLaneH = LANE_HEIGHT + TRACK_PADDING * 2 ;
float btnX = labelRect . xMax - EXPAND_BUTTON_SIZE - 4f ;
float btnY = labelRect . y + ( firstLaneH - EXPAND_BUTTON_SIZE ) * 0.5f ;
Rect btnRect = new Rect ( btnX , btnY , EXPAND_BUTTON_SIZE , EXPAND_BUTTON_SIZE ) ;
EditorGUI . DrawRect ( btnRect , new Color ( 0.25f , 0.25f , 0.25f , 0.8f ) ) ;
Vector3 center = btnRect . center ;
float halfSize = EXPAND_BUTTON_SIZE * 0.25f ;
Handles . color = isExpanded ? SoloIndicatorColor : ExpandButtonColor ;
if ( isExpanded )
{
Handles . DrawAAConvexPolygon (
new Vector3 ( center . x - halfSize , center . y - halfSize * 0.5f , 0 ) ,
new Vector3 ( center . x + halfSize , center . y - halfSize * 0.5f , 0 ) ,
new Vector3 ( center . x , center . y + halfSize * 0.5f , 0 )
) ;
}
else
{
Handles . DrawAAConvexPolygon (
new Vector3 ( center . x - halfSize * 0.5f , center . y - halfSize , 0 ) ,
new Vector3 ( center . x + halfSize * 0.5f , center . y , 0 ) ,
new Vector3 ( center . x - halfSize * 0.5f , center . y + halfSize , 0 )
) ;
}
// lane 数量角标
GUIStyle badgeStyle = new GUIStyle ( EditorStyles . miniLabel )
{
fontSize = 8 ,
normal = { textColor = new Color ( 0.7f , 0.7f , 0.7f ) } ,
alignment = TextAnchor . MiddleCenter
} ;
GUI . Label ( new Rect ( btnRect . xMax - 2f , btnRect . y - 2f , 12f , 10f ) , laneCount . ToString ( ) , badgeStyle ) ;
// 点击
if ( Event . current . type = = EventType . MouseDown & & Event . current . button = = 0
& & btnRect . Contains ( Event . current . mousePosition ) )
{
if ( isExpanded )
_expandedTracks . Remove ( trackIdx ) ;
else
_expandedTracks . Add ( trackIdx ) ;
_isDirty = true ;
Event . current . Use ( ) ;
}
}
// ─────────────── Clip 绘制 ───────────────
private void DrawClip ( Rect contentRect , FeedbackClip clip , int trackIdx , int clipIdx , float duration , int lane , bool isExpanded )
{
if ( clip = = null | | duration < = 0 ) return ;
float startNorm = clip . startTime / duration ;
float endNorm = clip . EndTime / duration ;
float startX = contentRect . x + startNorm * contentRect . width ;
float endX = contentRect . x + endNorm * contentRect . width ;
float clipWidth = Mathf . Max ( endX - startX , 2f ) ;
float clipY , clipH ;
if ( isExpanded )
{
clipY = contentRect . y + TRACK_PADDING + lane * LANE_HEIGHT + 2f ;
clipH = LANE_HEIGHT - 4f ;
}
else
{
clipY = contentRect . y + TRACK_PADDING + 2f ;
clipH = LANE_HEIGHT - 4f ;
}
Rect clipRect = new Rect ( startX , clipY , clipWidth , clipH ) ;
// Clip 填充色
Color clipColor = GetActionColor ( clip . action ) ;
bool isSelected = ( trackIdx = = _selectedTrackIndex & & clipIdx = = _selectedClipIndex ) ;
if ( isSelected )
{
clipColor = Color . Lerp ( clipColor , Color . white , 0.2f ) ;
}
EditorGUI . DrawRect ( clipRect , clipColor ) ;
// 选中描边
if ( isSelected )
{
DrawRectOutline ( clipRect , SelectionOutlineColor , 1f ) ;
}
// Clip 标签
string clipLabel = clip . action ! = null ? clip . action . DisplayName : "(null)" ;
GUIStyle clipLabelStyle = new GUIStyle ( EditorStyles . miniLabel )
{
normal = { textColor = Color . white } ,
clipping = TextClipping . Clip ,
alignment = TextAnchor . MiddleLeft ,
padding = new RectOffset ( 4 , 2 , 0 , 0 ) ,
fontSize = 10
} ;
if ( clipRect . width > 20f )
{
GUI . Label ( clipRect , clipLabel , clipLabelStyle ) ;
}
// 左右拖拽把手的视觉提示
if ( clipRect . width > DRAG_HANDLE_WIDTH * 3 )
{
Color handleColor = new Color ( 1f , 1f , 1f , 0.15f ) ;
Rect leftHandle = new Rect ( clipRect . x , clipRect . y , DRAG_HANDLE_WIDTH , clipRect . height ) ;
Rect rightHandle = new Rect ( clipRect . xMax - DRAG_HANDLE_WIDTH , clipRect . y , DRAG_HANDLE_WIDTH , clipRect . height ) ;
EditorGUI . DrawRect ( leftHandle , handleColor ) ;
EditorGUI . DrawRect ( rightHandle , handleColor ) ;
}
// Tooltip
string tooltip = $"{clipLabel}\n{clip.startTime:F3}s - {clip.EndTime:F3}s (dur: {clip.duration:F3}s)" ;
GUI . Label ( clipRect , new GUIContent ( "" , tooltip ) ) ;
}
/// <summary>
/// 绘制矩形描边。
/// </summary>
private void DrawRectOutline ( Rect rect , Color color , float thickness )
{
EditorGUI . DrawRect ( new Rect ( rect . x , rect . y , rect . width , thickness ) , color ) ;
EditorGUI . DrawRect ( new Rect ( rect . x , rect . yMax - thickness , rect . width , thickness ) , color ) ;
EditorGUI . DrawRect ( new Rect ( rect . x , rect . y , thickness , rect . height ) , color ) ;
EditorGUI . DrawRect ( new Rect ( rect . xMax - thickness , rect . y , thickness , rect . height ) , color ) ;
}
// ─────────────── Action 颜色 ───────────────
/// <summary>
/// 获取 Action 的时间轴显示颜色(优先从 FeedbackActionColorAttribute 读取,否则用类型哈希生成)。
/// </summary>
private Color GetActionColor ( FeedbackActionBase action )
{
if ( action = = null ) return DefaultClipColor ;
Type type = action . GetType ( ) ;
if ( ActionColorCache . TryGetValue ( type , out Color cached ) ) return cached ;
var attr = type . GetCustomAttribute < FeedbackActionColorAttribute > ( ) ;
Color color ;
if ( attr ! = null )
{
color = attr . Color ;
}
else
{
// 根据类型名哈希生成确定性颜色
int hash = type . FullName ? . GetHashCode ( ) ? ? 0 ;
float h = Mathf . Abs ( hash % 360 ) / 360f ;
color = Color . HSVToRGB ( h , 0.5f , 0.75f ) ;
color . a = 0.8f ;
}
ActionColorCache [ type ] = color ;
return color ;
}
// ═══════════════════════════════════════════
// 鼠标输入处理
// ═══════════════════════════════════════════
private void HandleMouseInput ( Rect totalArea , Rect rulerArea , Rect allTracksArea , float duration )
{
Event e = Event . current ;
Vector2 mousePos = e . mousePosition ;
if ( ! totalArea . Contains ( mousePos ) )
{
if ( e . type = = EventType . MouseUp ) ResetDrag ( ) ;
return ;
}
UpdateMouseCursor ( allTracksArea , mousePos , duration ) ;
switch ( e . type )
{
case EventType . MouseDown when e . button = = 0 :
HandleMouseDown ( allTracksArea , mousePos , duration , e ) ;
break ;
case EventType . MouseDrag when _currentDragMode ! = DragMode . None :
HandleMouseDrag ( allTracksArea , mousePos , duration , e ) ;
break ;
case EventType . MouseUp when e . button = = 0 :
ResetDrag ( ) ;
_isDirty = true ;
e . Use ( ) ;
break ;
}
}
private void HandleMouseDown ( Rect allTracksArea , Vector2 mousePos , float duration , Event e )
{
if ( ! allTracksArea . Contains ( mousePos ) ) return ;
Undo . RecordObject ( targetData , "Modify Feedback Timeline" ) ;
var hit = HitTestClip ( allTracksArea , mousePos , duration ) ;
if ( hit . trackIndex ! = - 1 )
{
_selectedTrackIndex = hit . trackIndex ;
_selectedClipIndex = hit . clipIndex ;
Rect contentRect = GetTrackContentRect ( allTracksArea , hit . trackIndex ) ;
float mouseTime = PixelToTime ( mousePos . x , contentRect , duration ) ;
FeedbackClip clip = targetData . tracks [ hit . trackIndex ] . clips [ hit . clipIndex ] ;
_dragStartMouseTime = mouseTime ;
_dragStartClipStart = clip . startTime ;
_dragStartClipDuration = clip . duration ;
_currentDragMode = hit . dragMode ;
_dragTrackIndex = hit . trackIndex ;
_dragClipIndex = hit . clipIndex ;
_isDirty = true ;
e . Use ( ) ;
return ;
}
// 点击空白 → 取消选中
_selectedTrackIndex = - 1 ;
_selectedClipIndex = - 1 ;
_isDirty = true ;
e . Use ( ) ;
}
private void HandleMouseDrag ( Rect allTracksArea , Vector2 mousePos , float duration , Event e )
{
if ( _dragTrackIndex < 0 | | _dragTrackIndex > = targetData . tracks . Count ) return ;
var track = targetData . tracks [ _dragTrackIndex ] ;
if ( _dragClipIndex < 0 | | _dragClipIndex > = track . clips . Count ) return ;
Rect contentRect = GetTrackContentRect ( allTracksArea , _dragTrackIndex ) ;
float mouseTime = PixelToTime ( mousePos . x , contentRect , duration ) ;
float timeDelta = mouseTime - _dragStartMouseTime ;
FeedbackClip clip = track . clips [ _dragClipIndex ] ;
switch ( _currentDragMode )
{
case DragMode . ClipMove :
float newStart = SnapTime ( _dragStartClipStart + timeDelta ) ;
newStart = Mathf . Max ( 0 , newStart ) ;
clip . startTime = newStart ;
break ;
case DragMode . ClipLeft :
float newLeftStart = SnapTime ( _dragStartClipStart + timeDelta ) ;
newLeftStart = Mathf . Max ( 0 , newLeftStart ) ;
float maxLeft = _dragStartClipStart + _dragStartClipDuration - MIN_CLIP_DURATION ;
newLeftStart = Mathf . Min ( newLeftStart , maxLeft ) ;
float endTime = _dragStartClipStart + _dragStartClipDuration ;
clip . startTime = newLeftStart ;
clip . duration = Mathf . Max ( endTime - newLeftStart , MIN_CLIP_DURATION ) ;
break ;
case DragMode . ClipRight :
float newDuration = SnapTime ( _dragStartClipDuration + timeDelta ) ;
clip . duration = Mathf . Max ( newDuration , MIN_CLIP_DURATION ) ;
break ;
}
EditorUtility . SetDirty ( targetData ) ;
_isDirty = true ;
e . Use ( ) ;
}
private void ResetDrag ( )
{
_currentDragMode = DragMode . None ;
_dragTrackIndex = - 1 ;
_dragClipIndex = - 1 ;
}
// ─────────────── Hit Testing ───────────────
private struct ClipHitResult
{
public int trackIndex ;
public int clipIndex ;
public DragMode dragMode ;
}
/// <summary>
/// 测试鼠标位置是否命中某个 Clip, 并返回拖拽模式。
/// </summary>
private ClipHitResult HitTestClip ( Rect allTracksArea , Vector2 mousePos , float duration )
{
var result = new ClipHitResult { trackIndex = - 1 , clipIndex = - 1 , dragMode = DragMode . None } ;
if ( targetData . tracks = = null | | duration < = 0 | | _trackLayouts = = null ) return result ;
// 通过 Y 坐标确定轨道
int trackIndex = - 1 ;
for ( int i = 0 ; i < _trackLayouts . Length ; i + + )
{
float trackY = allTracksArea . y + _trackLayouts [ i ] . yOffset ;
float trackBottom = trackY + _trackLayouts [ i ] . totalHeight ;
if ( mousePos . y > = trackY & & mousePos . y < trackBottom )
{
trackIndex = i ;
break ;
}
}
if ( trackIndex < 0 | | trackIndex > = targetData . tracks . Count ) return result ;
Rect contentRect = GetTrackContentRect ( allTracksArea , trackIndex ) ;
if ( ! contentRect . Contains ( mousePos ) ) return result ;
var track = targetData . tracks [ trackIndex ] ;
if ( track . clips = = null ) return result ;
bool isExpanded = _expandedTracks . Contains ( trackIndex ) ;
ref TrackLayout layout = ref _trackLayouts [ trackIndex ] ;
for ( int i = track . clips . Count - 1 ; i > = 0 ; i - - )
{
FeedbackClip clip = track . clips [ i ] ;
if ( clip = = null ) continue ;
float startX = contentRect . x + ( clip . startTime / duration ) * contentRect . width ;
float endX = contentRect . x + ( clip . EndTime / duration ) * contentRect . width ;
if ( mousePos . x < startX | | mousePos . x > endX ) continue ;
// 展开模式下检查 Y 方向 lane
if ( isExpanded & & layout . clipLanes ! = null & & i < layout . clipLanes . Length )
{
int lane = layout . clipLanes [ i ] ;
float laneY = contentRect . y + TRACK_PADDING + lane * LANE_HEIGHT ;
if ( mousePos . y < laneY | | mousePos . y > laneY + LANE_HEIGHT ) continue ;
}
result . trackIndex = trackIndex ;
result . clipIndex = i ;
if ( mousePos . x - startX < = DRAG_HANDLE_WIDTH )
result . dragMode = DragMode . ClipLeft ;
else if ( endX - mousePos . x < = DRAG_HANDLE_WIDTH )
result . dragMode = DragMode . ClipRight ;
else
result . dragMode = DragMode . ClipMove ;
return result ;
}
return result ;
}
// ─────────────── 光标更新 ───────────────
private void UpdateMouseCursor ( Rect allTracksArea , Vector2 mousePos , float duration )
{
if ( ! allTracksArea . Contains ( mousePos ) ) return ;
var hit = HitTestClip ( allTracksArea , mousePos , duration ) ;
if ( hit . trackIndex = = - 1 )
{
EditorGUIUtility . AddCursorRect ( allTracksArea , MouseCursor . Arrow ) ;
return ;
}
switch ( hit . dragMode )
{
case DragMode . ClipLeft :
case DragMode . ClipRight :
EditorGUIUtility . AddCursorRect ( allTracksArea , MouseCursor . ResizeHorizontal ) ;
break ;
default :
EditorGUIUtility . AddCursorRect ( allTracksArea , MouseCursor . MoveArrow ) ;
break ;
}
}
// ─────────────── 工具方法 ───────────────
/// <summary>
/// 获取指定轨道的内容区域(不含标签列),高度由布局决定。
/// </summary>
private Rect GetTrackContentRect ( Rect allTracksArea , int trackIndex )
{
if ( _trackLayouts = = null | | trackIndex > = _trackLayouts . Length )
{
return new Rect (
allTracksArea . x + TRACK_LABEL_WIDTH ,
allTracksArea . y ,
allTracksArea . width - TRACK_LABEL_WIDTH ,
LANE_HEIGHT + TRACK_PADDING * 2
) ;
}
ref TrackLayout layout = ref _trackLayouts [ trackIndex ] ;
return new Rect (
allTracksArea . x + TRACK_LABEL_WIDTH ,
allTracksArea . y + layout . yOffset ,
allTracksArea . width - TRACK_LABEL_WIDTH ,
layout . totalHeight
) ;
}
/// <summary>
/// 像素坐标转换为时间值。
/// </summary>
private float PixelToTime ( float pixelX , Rect trackRect , float duration )
{
if ( trackRect . width < = 0 ) return 0 ;
return Mathf . Clamp ( ( pixelX - trackRect . x ) / trackRect . width * duration , 0 , duration ) ;
}
/// <summary>
/// 将时间吸附到最近的 snapInterval 刻度。
/// </summary>
private float SnapTime ( float time )
{
if ( snapInterval < = 0 ) return Mathf . Max ( 0 , time ) ;
return Mathf . Max ( 0 , Mathf . Round ( time / snapInterval ) * snapInterval ) ;
}
}
}
#endif