#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 _expandedTracks = new HashSet(); 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 ActionColorCache = new Dictionary(); 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; /// /// 包装每个 Editor 的绘制,使用 Odin 的 GUIHelper 栈式 Label 宽度, /// 确保 InlineEditor 内部也能生效。 /// protected override void DrawEditor(int index) { GUIHelper.PushLabelWidth(INSPECTOR_LABEL_WIDTH); base.DrawEditor(index); GUIHelper.PopLabelWidth(); } // ─────────────── 窗口入口 ─────────────── /// /// 通过菜单打开窗口。 /// [MenuItem("Tools/SLS Utilities/Feedback Data Editor")] private static void OpenWindow() { var window = GetWindow(); window.titleContent = new GUIContent("Feedback Editor"); window.Show(); } /// /// 双击 FeedbackData 资产时自动打开编辑器。 /// [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(); 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; } /// /// 自动适配 viewDuration 到数据实际长度。 /// private void FitViewDuration() { if (targetData == null) return; float total = targetData.TotalDuration; viewDuration = Mathf.Max(total * (1f + TIMELINE_PADDING_RATIO), MIN_TIMELINE_DURATION); } // ═══════════════════════════════════════════ // 轨道布局计算 // ═══════════════════════════════════════════ /// /// 重新计算所有轨道的布局(lane 分配、高度、Y 偏移)。 /// private void ComputeTrackLayouts() { if (targetData?.tracks == null || targetData.tracks.Count == 0) { _trackLayouts = Array.Empty(); 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; } } /// /// 为一条轨道中的 Clips 分配 lane,使重叠的 Clip 位于不同 lane。 /// 贪心算法:按 startTime 排序,依次放入第一个无冲突的 lane。 /// private int[] AssignClipLanes(FeedbackTrack track) { if (track?.clips == null || track.clips.Count == 0) return Array.Empty(); 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 laneEndTimes = new List(); 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); } /// /// 根据时间轴总时长和像素宽度计算合理的刻度间距。 /// 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); } } } /// /// 绘制轨道标签区域(名称 + mute/solo 指示 + 展开按钮)。 /// 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); } } /// /// 绘制展开/折叠三角按钮。 /// 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)); } /// /// 绘制矩形描边。 /// 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 颜色 ─────────────── /// /// 获取 Action 的时间轴显示颜色(优先从 FeedbackActionColorAttribute 读取,否则用类型哈希生成)。 /// 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(); 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; } /// /// 测试鼠标位置是否命中某个 Clip,并返回拖拽模式。 /// 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; } } // ─────────────── 工具方法 ─────────────── /// /// 获取指定轨道的内容区域(不含标签列),高度由布局决定。 /// 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 ); } /// /// 像素坐标转换为时间值。 /// 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); } /// /// 将时间吸附到最近的 snapInterval 刻度。 /// private float SnapTime(float time) { if (snapInterval <= 0) return Mathf.Max(0, time); return Mathf.Max(0, Mathf.Round(time / snapInterval) * snapInterval); } } } #endif