Files
Continentis/Assets/Scripts/SLSUtilities/Feedback/Editor/FeedbackDataEditorWindow.cs
SoulliesOfficial ac98ec3aef 更新
2026-04-17 12:01:50 -04:00

699 lines
27 KiB
C#
Raw Permalink 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.Reflection;
using UnityEngine;
using UnityEditor;
using Sirenix.OdinInspector;
using Sirenix.OdinInspector.Editor;
using UnityEditor.Callbacks;
namespace SLSUtilities.Feedback.Editor
{
public class FeedbackDataEditorWindow : OdinEditorWindow
{
// ─────────────── 常量 ───────────────
private const float RULER_HEIGHT = 22f;
private const float TRACK_HEIGHT = 28f;
private const float TRACK_LABEL_WIDTH = 140f;
private const float TRACK_BUTTON_WIDTH = 40f;
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 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 PlayheadColor = new Color(1f, 1f, 1f, 0.6f);
// ─────────────── 窗口入口 ───────────────
/// <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.InstanceIDToObject(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;
}
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);
}
// ═══════════════════════════════════════════
// 时间轴 GUI 主入口
// ═══════════════════════════════════════════
[OnInspectorGUI]
[PropertyOrder(-98)]
[ShowIf("targetData")]
private void DrawTimelineGUI()
{
if (targetData == null) return;
if (autoFitDuration) FitViewDuration();
int numTracks = targetData.tracks != null ? targetData.tracks.Count : 0;
float totalHeight = RULER_HEIGHT + Mathf.Max(numTracks, 1) * TRACK_HEIGHT;
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,
totalHeight - RULER_HEIGHT
);
// 背景
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)
{
GUIStyle emptyStyle = new GUIStyle(EditorStyles.centeredGreyMiniLabel);
GUI.Label(allTracksArea, "No tracks. Add tracks in the inspector below.", emptyStyle);
return;
}
float currentY = allTracksArea.y;
for (int trackIdx = 0; trackIdx < targetData.tracks.Count; trackIdx++)
{
FeedbackTrack track = targetData.tracks[trackIdx];
Rect rowRect = new Rect(allTracksArea.x, currentY, allTracksArea.width, TRACK_HEIGHT);
Rect labelRect = new Rect(rowRect.x, rowRect.y, TRACK_LABEL_WIDTH, rowRect.height - 1);
Rect trackRect = new Rect(
rowRect.x + TRACK_LABEL_WIDTH,
rowRect.y,
rowRect.width - TRACK_LABEL_WIDTH,
rowRect.height - 1
);
// 轨道背景(交替色)
Color bgColor = (trackIdx % 2 == 0) ? TrackBackgroundColor : TrackAltBackgroundColor;
EditorGUI.DrawRect(trackRect, bgColor);
// 轨道标签
DrawTrackLabel(labelRect, track, trackIdx);
// 轨道中的 Clip
if (track.clips != null)
{
for (int clipIdx = 0; clipIdx < track.clips.Count; clipIdx++)
{
DrawClip(trackRect, track.clips[clipIdx], trackIdx, clipIdx, duration);
}
}
// Mute 叠加层
if (track.mute)
{
EditorGUI.DrawRect(trackRect, MutedOverlayColor);
}
currentY += TRACK_HEIGHT;
}
}
/// <summary>
/// 绘制轨道标签区域(名称 + mute/solo 指示)。
/// </summary>
private void DrawTrackLabel(Rect labelRect, FeedbackTrack track, int trackIdx)
{
EditorGUI.DrawRect(labelRect, RulerBackgroundColor);
// Solo 指示条
if (track.solo)
{
Rect soloBar = new Rect(labelRect.x, labelRect.y, 3f, labelRect.height);
EditorGUI.DrawRect(soloBar, SoloIndicatorColor);
}
GUIStyle labelStyle = new GUIStyle(EditorStyles.label)
{
fontSize = 11,
padding = new RectOffset(8, 0, 0, 0)
};
string prefix = "";
if (track.mute) prefix = "[M] ";
if (track.solo) prefix = "[S] ";
string displayName = string.IsNullOrEmpty(track.trackName) ? $"Track {trackIdx}" : track.trackName;
labelStyle.normal.textColor = track.mute
? new Color(0.5f, 0.5f, 0.5f)
: new Color(0.85f, 0.85f, 0.85f);
EditorGUI.LabelField(labelRect, $"{prefix}{displayName}", labelStyle);
}
// ─────────────── Clip 绘制 ───────────────
private void DrawClip(Rect trackRect, FeedbackClip clip, int trackIdx, int clipIdx, float duration)
{
if (clip == null || duration <= 0) return;
float startNorm = clip.startTime / duration;
float endNorm = clip.EndTime / duration;
float startX = trackRect.x + startNorm * trackRect.width;
float endX = trackRect.x + endNorm * trackRect.width;
float width = Mathf.Max(endX - startX, 2f);
Rect clipRect = new Rect(startX, trackRect.y + 2f, width, trackRect.height - 4f);
// 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 trackRect = GetTrackContentRect(allTracksArea, hit.trackIndex);
float mouseTime = PixelToTime(mousePos.x, trackRect, 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 trackRect = GetTrackContentRect(allTracksArea, _dragTrackIndex);
float mouseTime = PixelToTime(mousePos.x, trackRect, 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) return result;
// 确定鼠标所在轨道
float yInTracks = mousePos.y - allTracksArea.y;
int trackIndex = Mathf.FloorToInt(yInTracks / TRACK_HEIGHT);
if (trackIndex < 0 || trackIndex >= targetData.tracks.Count) return result;
Rect trackRect = GetTrackContentRect(allTracksArea, trackIndex);
if (!trackRect.Contains(mousePos)) return result;
var track = targetData.tracks[trackIndex];
if (track.clips == null) return result;
// 从后往前检测(后绘制的优先)
for (int i = track.clips.Count - 1; i >= 0; i--)
{
FeedbackClip clip = track.clips[i];
if (clip == null) continue;
float startX = trackRect.x + (clip.startTime / duration) * trackRect.width;
float endX = trackRect.x + (clip.EndTime / duration) * trackRect.width;
if (mousePos.x >= startX && mousePos.x <= endX)
{
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)
{
return new Rect(
allTracksArea.x + TRACK_LABEL_WIDTH,
allTracksArea.y + trackIndex * TRACK_HEIGHT,
allTracksArea.width - TRACK_LABEL_WIDTH,
TRACK_HEIGHT - 1
);
}
/// <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