957 lines
37 KiB
C#
957 lines
37 KiB
C#
#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
|