Files
Cielonos/Assets/Scripts/SLSUtilities/Feedback/Editor/FeedbackDataEditorWindow.cs
2026-04-18 13:57:19 -04:00

957 lines
37 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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