This commit is contained in:
SoulliesOfficial
2026-04-17 12:01:50 -04:00
parent dd2657573a
commit ac98ec3aef
438 changed files with 4505 additions and 428 deletions

View File

@@ -3,7 +3,7 @@ using System.Collections.Generic;
using Continentis.MainGame.Character;
using Cysharp.Threading.Tasks;
namespace SLSFramework.General
namespace SLSUtilities.General
{
/// <summary>
/// 命令静态工厂。

View File

@@ -1,7 +1,7 @@
using System;
using Cysharp.Threading.Tasks;
namespace SLSFramework.General
namespace SLSUtilities.General
{
/// <summary>
/// 命令基类。子类重写 <see cref="ExecuteAsync"/> 实现具体逻辑。

View File

@@ -1,7 +1,7 @@
using System;
using System.Collections.Generic;
namespace SLSFramework.General
namespace SLSUtilities.General
{
/// <summary>
/// 指令上下文 (Command Context)

View File

@@ -1,4 +1,4 @@
namespace SLSFramework.General
namespace SLSUtilities.General
{
/// <summary>
/// 框架预定义的 CommandContext 键常量。

View File

@@ -2,7 +2,7 @@ using System.Collections.Generic;
using System.Linq;
using Cysharp.Threading.Tasks;
namespace SLSFramework.General
namespace SLSUtilities.General
{
/// <summary>命令组的执行模式。</summary>
public enum ExecutionMode

View File

@@ -1,4 +1,4 @@
namespace SLSFramework.General
namespace SLSUtilities.General
{
/// <summary>
/// 命令队列的优先级分桶。数值越小优先级越高。

View File

@@ -2,7 +2,7 @@ using System.Collections.Generic;
using Cysharp.Threading.Tasks;
using UnityEngine;
namespace SLSFramework.General
namespace SLSUtilities.General
{
/// <summary>
/// 全局命令队列管理器。

View File

@@ -1,8 +1,8 @@
using Cysharp.Threading.Tasks;
using SLSFramework.General;
using SLSUtilities.General;
using UnityEngine;
namespace SLSFramework.General
namespace SLSUtilities.General
{
/// <summary>从 outerContext 中读取一个变量并输出到控制台。</summary>
public class Cmd_GetAndLogVariable : CommandBase

View File

@@ -1,8 +1,8 @@
using Cysharp.Threading.Tasks;
using SLSFramework.General;
using SLSUtilities.General;
using UnityEngine;
namespace SLSFramework.General
namespace SLSUtilities.General
{
/// <summary>在 outerContext 中设置一个键值对。</summary>
public class Cmd_SetVariable : CommandBase

View File

@@ -1,9 +1,9 @@
using System;
using Cysharp.Threading.Tasks;
using SLSFramework.General;
using SLSUtilities.General;
using UnityEngine;
namespace SLSFramework.General
namespace SLSUtilities.General
{
/// <summary>等待指定秒数后在控制台输出一条信息。</summary>
public class Cmd_WaitAndLog : CommandBase

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 90dde392a034bae49933eab85866c2cc
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 1ad1a9604de9797448b5779a5a190c32
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,63 @@
using System;
using Sirenix.OdinInspector;
using UnityEngine;
namespace SLSUtilities.Feedback
{
/// <summary>
/// 通用的"按曲线震动数值"基类,提供曲线采样、初始值记录/复位等通用逻辑。
/// RadialBlur、ChromaticAberration、Vignette 等后处理效果均继承此类。
/// </summary>
[Serializable]
public abstract class CurveShakeAction : FeedbackActionBase
{
/// <summary>
/// 震动曲线X 轴为归一化时间 [0,1]Y 轴为震动强度 [0,1]。
/// </summary>
[Title("Curve Shake")]
[LabelText("Shake Curve")]
public AnimationCurve shakeCurve = new AnimationCurve(
new Keyframe(0f, 0f),
new Keyframe(0.5f, 1f),
new Keyframe(1f, 0f)
);
/// <summary>
/// 曲线值 0 对应的实际数值。
/// </summary>
[LabelText("Remap Min")]
public float remapMin;
/// <summary>
/// 曲线值 1 对应的实际数值。
/// </summary>
[LabelText("Remap Max")]
public float remapMax = 1f;
/// <summary>
/// 是否在初始值上叠加(而非替换)。
/// </summary>
[LabelText("Relative to Initial")]
public bool relativeToInitial;
/// <summary>
/// 根据归一化时间采样曲线并映射到实际值范围。
/// 如果 relativeToInitial 为 true结果会叠加在 initialValue 上。
/// </summary>
/// <param name="normalizedTime">归一化时间 [0,1]</param>
/// <param name="initialValue">初始值OnStart 时记录)</param>
/// <returns>映射后的最终数值</returns>
protected float EvaluateShake(float normalizedTime, float initialValue)
{
float curveValue = shakeCurve.Evaluate(normalizedTime);
float remappedValue = Mathf.LerpUnclamped(remapMin, remapMax, curveValue);
if (relativeToInitial)
{
return initialValue + remappedValue;
}
return remappedValue;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 26b145321a43fe44d899db3f2178cb0e

View File

@@ -0,0 +1,85 @@
using System;
using Sirenix.OdinInspector;
using UnityEngine;
namespace SLSUtilities.Feedback
{
/// <summary>
/// 反馈播放上下文,传递给每个 FeedbackAction 的生命周期回调。
/// </summary>
public struct FeedbackContext
{
/// <summary>
/// 当前播放器实例。
/// </summary>
public FeedbackPlayer player;
/// <summary>
/// 触发者的 Transform可选
/// </summary>
public Transform owner;
/// <summary>
/// 当前帧经过时间缩放处理后的 deltaTime。
/// </summary>
public float deltaTime;
/// <summary>
/// Clip 已播放时间(秒)。
/// </summary>
public float elapsedTime;
/// <summary>
/// Clip 总时长(秒)。
/// </summary>
public float duration;
}
/// <summary>
/// 所有反馈动作的抽象基类,定义生命周期回调。
/// 使用 Odin 的序列化路径实现多态序列化SerializedScriptableObject
/// Odin 会自动为此抽象类型的字段显示多态类型选择器。
/// </summary>
[Serializable]
public abstract class FeedbackActionBase
{
/// <summary>
/// Inspector 中显示的名称。
/// </summary>
public virtual string DisplayName => GetType().Name;
/// <summary>
/// 初始化FeedbackPlayer 开始播放此 Clip 时调用。
/// </summary>
public virtual void OnStart(FeedbackContext context) { }
/// <summary>
/// 每帧更新normalizedTime 为 Clip 内的归一化进度 [0,1]。
/// </summary>
public virtual void OnUpdate(FeedbackContext context, float normalizedTime) { }
/// <summary>
/// Clip 自然结束时调用。
/// </summary>
public virtual void OnEnd(FeedbackContext context) { }
/// <summary>
/// 被打断时调用,负责立即复位到初始状态。
/// </summary>
public virtual void OnInterrupt(FeedbackContext context) { }
/// <summary>
/// 用于验证配置是否正确Editor 环境)。
/// </summary>
public virtual bool Validate(out string error)
{
error = null;
return true;
}
/// <summary>
/// 用于 Editor 预览Runtime 也可用)。
/// </summary>
public virtual void Preview() { }
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: eea5c9e5f4713f2438de3ca97bf00cba

View File

@@ -0,0 +1,19 @@
using System;
using UnityEngine;
namespace SLSUtilities.Feedback
{
/// <summary>
/// 为 FeedbackActionBase 子类指定在时间轴编辑器中的显示颜色。
/// </summary>
[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
public sealed class FeedbackActionColorAttribute : Attribute
{
public Color Color { get; }
public FeedbackActionColorAttribute(float r, float g, float b, float a = 0.8f)
{
Color = new Color(r, g, b, a);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 460e3071059bce248806e5bb6f81d27e

View File

@@ -0,0 +1,49 @@
using System;
using Sirenix.OdinInspector;
using UnityEngine;
namespace SLSUtilities.Feedback
{
/// <summary>
/// 轨道上的一个时间片段,包含一个 FeedbackAction 及其时间参数。
/// 曲线控制完全下放到具体 Action 中Clip 只负责时间调度。
/// </summary>
[Serializable]
public class FeedbackClip
{
/// <summary>
/// 片段开始时间(秒)。
/// </summary>
[MinValue(0f)]
public float startTime;
/// <summary>
/// 片段持续时间(秒)。
/// </summary>
[MinValue(0.01f)]
public float duration = 0.1f;
/// <summary>
/// 片段结束时间(秒)。
/// </summary>
public float EndTime => startTime + duration;
/// <summary>
/// 是否覆盖 FeedbackData 的时间设置。
/// </summary>
[Title("Time Override")]
public bool overrideTimeSettings;
/// <summary>
/// 覆盖用的时间设置,仅在 overrideTimeSettings 为 true 时生效。
/// </summary>
[ShowIf("overrideTimeSettings")]
public FeedbackTimeSettings timeSettings = new FeedbackTimeSettings();
/// <summary>
/// 具体反馈动作Odin 自动显示多态类型选择器。
/// </summary>
[Title("Action"), SerializeReference]
public FeedbackActionBase action;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 05ae22a20d69f0e4fa3009d721f2331d

View File

@@ -0,0 +1,70 @@
using System.Collections.Generic;
using System.Linq;
using Sirenix.OdinInspector;
using UnityEngine;
namespace SLSUtilities.Feedback
{
/// <summary>
/// 单个反馈序列的完整数据定义,是 Feedback 系统的核心 ScriptableObject。
/// 包含多条轨道Track每条轨道包含按时间排列的片段Clip
/// </summary>
[CreateAssetMenu(fileName = "NewFeedbackData", menuName = "SLS/Feedback/FeedbackData")]
public class FeedbackData : SerializedScriptableObject
{
/// <summary>
/// 父级集合引用,由 FeedbackDataCollection 自动维护。
/// </summary>
[ReadOnly, ShowInInspector]
public FeedbackDataCollection parentCollection;
/// <summary>
/// 用于字典索引的名称,在 FeedbackDataCollection 中按此名称查找。
/// </summary>
[Title("Editor Settings")]
public string feedbackName;
/// <summary>
/// 全局默认的时间设置。Clip 可选择覆盖此设置。
/// </summary>
[Title("Time Settings (Default)")]
public FeedbackTimeSettings defaultTimeSettings = new FeedbackTimeSettings();
/// <summary>
/// 反馈轨道列表,多条轨道天然并行播放。
/// </summary>
[Title("Feedback Tracks")]
[ListDrawerSettings(ShowFoldout = true, ListElementLabelName = "trackName")]
public List<FeedbackTrack> tracks = new List<FeedbackTrack>();
/// <summary>
/// 所有轨道的最大时长。
/// </summary>
public float TotalDuration => tracks.Count > 0 ? tracks.Max(t => t.TotalDuration) : 0f;
/// <summary>
/// 运行时预览:通过 FeedbackManager 播放此反馈。
/// 仅在 Play 模式下可用。
/// </summary>
[Button("Preview", ButtonSizes.Medium)]
[EnableIf("@UnityEngine.Application.isPlaying")]
public void Preview()
{
if (!Application.isPlaying)
{
Debug.LogWarning("[FeedbackData] Preview is only available in Play mode.");
return;
}
if (FeedbackManager.Instance == null)
{
Debug.LogWarning("[FeedbackData] Preview failed: FeedbackManager not found in scene. " +
"Add a GameObject with FeedbackManager component.");
return;
}
FeedbackPlayer player = FeedbackManager.Instance.Play(this);
Debug.Log($"[FeedbackData] Previewing '{feedbackName}' (Duration: {TotalDuration:F2}s)");
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 87e69f21423d3c746ae55ea47c545ba6

View File

@@ -0,0 +1,115 @@
using System.Collections.Generic;
using System.Linq;
using Sirenix.OdinInspector;
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif
namespace SLSUtilities.Feedback
{
/// <summary>
/// FeedbackData 的容器,供策划在武器 Prefab 上配置多个反馈序列。
/// 支持按 feedbackName 索引查找。
/// </summary>
[CreateAssetMenu(fileName = "FeedbackDataCollection", menuName = "SLS/Feedback/FeedbackDataCollection")]
public class FeedbackDataCollection : SerializedScriptableObject
{
/// <summary>
/// 反馈数据列表。
/// </summary>
[Searchable]
[ListDrawerSettings(ShowFoldout = true, CustomRemoveIndexFunction = "OnRemoveItem")]
[OnValueChanged("OnListChanged", true)]
public List<FeedbackData> feedbackDataList = new List<FeedbackData>();
/// <summary>
/// 按 feedbackName 索引查找 FeedbackData。
/// </summary>
public FeedbackData this[string name]
{
get
{
if (feedbackDataList == null || string.IsNullOrEmpty(name)) return null;
return feedbackDataList.FirstOrDefault(d => d != null && d.feedbackName == name);
}
}
/// <summary>
/// 尝试按名称获取 FeedbackData。
/// </summary>
public bool TryGet(string name, out FeedbackData data)
{
data = this[name];
return data != null;
}
/// <summary>
/// 当列表发生任何变化(添加、拖入、重新排序)时调用,维护父子引用。
/// </summary>
private void OnListChanged()
{
if (feedbackDataList == null) return;
foreach (var data in feedbackDataList)
{
if (data != null && data.parentCollection != this)
{
data.parentCollection = this;
#if UNITY_EDITOR
EditorUtility.SetDirty(data);
#endif
}
}
}
/// <summary>
/// 自定义删除逻辑:先解绑父级引用,再从列表中移除。
/// </summary>
private void OnRemoveItem(int index)
{
if (index < 0 || index >= feedbackDataList.Count) return;
FeedbackData dataToRemove = feedbackDataList[index];
if (dataToRemove != null)
{
if (dataToRemove.parentCollection == this)
{
dataToRemove.parentCollection = null;
#if UNITY_EDITOR
EditorUtility.SetDirty(dataToRemove);
#endif
}
}
feedbackDataList.RemoveAt(index);
#if UNITY_EDITOR
EditorUtility.SetDirty(this);
#endif
}
/// <summary>
/// 运行时按名称预览指定的 FeedbackData。
/// </summary>
[Button("Preview by Name")]
[EnableIf("@UnityEngine.Application.isPlaying")]
public void PreviewByName(string name)
{
if (!Application.isPlaying)
{
Debug.LogWarning("[FeedbackDataCollection] Preview is only available in Play mode.");
return;
}
if (TryGet(name, out FeedbackData data))
{
data.Preview();
}
else
{
Debug.LogWarning($"[FeedbackDataCollection] FeedbackData with name '{name}' not found.");
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 693e7631325261949bb90a5df6789240

View File

@@ -0,0 +1,34 @@
using System;
using UnityEngine;
namespace SLSUtilities.Feedback
{
/// <summary>
/// 时间设置数据结构,控制 Feedback 的时间缩放来源。
/// 同时存在于 FeedbackData全局默认和 FeedbackClip单元覆盖两个层级。
/// Clip 级设置如果 useTimeScale = true 则覆盖 Data 级设置。
/// </summary>
[Serializable]
public class FeedbackTimeSettings
{
/// <summary>
/// 是否使用时间缩放。默认 false 表示不受任何时间缩放影响。
/// </summary>
public bool useTimeScale;
/// <summary>
/// 受 TimeManager.globalTimeScale 影响。
/// </summary>
public bool affectedByGlobalTimeScale;
/// <summary>
/// 受 TimeManager 的分组时间影响player/enemy 等)。
/// </summary>
public bool affectedByGroupTimeScale;
/// <summary>
/// 受角色本地 localTimeScale 影响。
/// </summary>
public bool affectedByLocalTimeScale;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b3b96365b54adee4887c91d228fcf159

View File

@@ -0,0 +1,46 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Sirenix.OdinInspector;
namespace SLSUtilities.Feedback
{
/// <summary>
/// 一条反馈轨道,包含按时间排列的 Clip 序列。
/// 多个 Track 天然并行播放Track 内的 Clip 按时间顺序排列,不重叠。
/// </summary>
[Serializable]
public class FeedbackTrack
{
/// <summary>
/// 轨道名称,用于调试和 Inspector 显示。
/// </summary>
[LabelText("Track Name")]
public string trackName = "New Track";
/// <summary>
/// 静音此轨道,播放时跳过。
/// </summary>
[HorizontalGroup("Flags", Width = 60)]
[LabelWidth(40)]
public bool mute;
/// <summary>
/// 独奏此轨道,仅播放标记为 Solo 的轨道。
/// </summary>
[HorizontalGroup("Flags", Width = 50)]
[LabelWidth(35)]
public bool solo;
/// <summary>
/// 轨道上的片段列表。
/// </summary>
[ListDrawerSettings(ShowFoldout = true)]
public List<FeedbackClip> clips = new List<FeedbackClip>();
/// <summary>
/// 该轨道的总时长,取所有 Clip 中最大的 EndTime。
/// </summary>
public float TotalDuration => clips.Count > 0 ? clips.Max(c => c.EndTime) : 0f;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8010f4b35c62b4c44b20ee97778cb33a

View File

@@ -0,0 +1,29 @@
namespace SLSUtilities.Feedback
{
/// <summary>
/// 时间提供者接口,框架层不依赖具体的 TimeManager 实现。
/// 游戏层通过 SelfTimeSubmodule 或适配器类实现此接口,注入到 FeedbackPlayer 中。
/// </summary>
public interface IFeedbackTimeProvider
{
/// <summary>
/// 全局时间缩放值。
/// </summary>
float GlobalTimeScale { get; }
/// <summary>
/// 分组时间缩放值,由具体实现根据 Fraction 返回对应值。
/// </summary>
float GroupTimeScale { get; }
/// <summary>
/// 角色本地时间缩放值。
/// </summary>
float LocalTimeScale { get; }
/// <summary>
/// 根据时间设置计算实际 deltaTime。
/// </summary>
float GetDeltaTime(FeedbackTimeSettings settings);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8c53b0168a5ad5f4daae818aab208d20

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: e04ed28b2e1c6ef4a90b9574d94efc60
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,698 @@
#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

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 91af4d70c106a2147a6b469af19db689

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c442c21f1abe1f943bc6f68f0a4bb394
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,98 @@
using System.Collections.Generic;
using SLSUtilities.General;
using UnityEngine;
namespace SLSUtilities.Feedback
{
/// <summary>
/// 全局 Feedback 播放管理器Singleton
/// 集中驱动所有注册的 FeedbackPlayer提供 Play / Stop 快捷 API。
/// 适用于不绑定特定角色时间缩放的"全局反馈"如后处理效果、UI 反馈等)。
/// 需要角色级时间缩放的反馈仍由 FeedbackSubcontroller 手动驱动。
/// </summary>
public class FeedbackManager : Singleton<FeedbackManager>
{
private const int INITIAL_CAPACITY = 64;
private readonly List<FeedbackPlayer> _activePlayers = new List<FeedbackPlayer>(INITIAL_CAPACITY);
/// <summary>
/// 当前活跃的 Player 数量。
/// </summary>
public int ActiveCount => _activePlayers.Count;
/// <summary>
/// 以全局方式播放一个 FeedbackData不绑定任何 owner 或 timeProvider。
/// </summary>
public FeedbackPlayer Play(FeedbackData data)
{
return Play(data, null, null);
}
/// <summary>
/// 播放一个 FeedbackData指定时间提供者和 owner。
/// </summary>
public FeedbackPlayer Play(FeedbackData data, IFeedbackTimeProvider timeProvider, Transform owner)
{
if (data == null)
{
Debug.LogWarning("[FeedbackManager] Cannot play: FeedbackData is null.");
return null;
}
var player = new FeedbackPlayer(data, timeProvider, owner);
player.Play();
_activePlayers.Add(player);
return player;
}
/// <summary>
/// 注册一个已有的 FeedbackPlayer 由管理器驱动。
/// 适用于外部创建 Player 后需要交给管理器集中管理的场景。
/// </summary>
public void Register(FeedbackPlayer player)
{
if (player == null || _activePlayers.Contains(player)) return;
_activePlayers.Add(player);
}
/// <summary>
/// 停止并移除指定的 FeedbackPlayer。
/// </summary>
public void Stop(FeedbackPlayer player)
{
if (player == null) return;
player.Stop();
_activePlayers.Remove(player);
}
/// <summary>
/// 停止所有活跃的 FeedbackPlayer。
/// </summary>
public void StopAll()
{
for (int i = _activePlayers.Count - 1; i >= 0; i--)
{
_activePlayers[i].Stop();
}
_activePlayers.Clear();
}
private void Update()
{
float dt = Time.unscaledDeltaTime;
for (int i = _activePlayers.Count - 1; i >= 0; i--)
{
FeedbackPlayer player = _activePlayers[i];
player.Tick(dt);
if (player.IsCompleted || !player.IsActive)
{
_activePlayers.RemoveAt(i);
}
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b5afafd522603d740a166204893eaad1

View File

@@ -0,0 +1,329 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace SLSUtilities.Feedback
{
/// <summary>
/// 反馈播放器状态枚举。
/// </summary>
public enum FeedbackPlayerState
{
Idle,
Playing,
Paused
}
/// <summary>
/// 运行时反馈播放器(纯 C# 类,非 MonoBehaviour管理一个 FeedbackData 的播放生命周期。
/// 由 FeedbackManager 的 Update 集中驱动,也可由外部(如 FeedbackSubcontroller手动驱动。
/// </summary>
public class FeedbackPlayer
{
private const float MIN_DURATION = 0.001f;
private FeedbackData _data;
private FeedbackPlayerState _state;
private float _currentTime;
private IFeedbackTimeProvider _timeProvider;
private Transform _ownerTransform;
private bool _isCompleted;
// 每个 Clip 的运行时状态
private enum ClipState { Pending, Active, Finished }
private ClipState[,] _clipStates; // [trackIndex, clipIndex]
private float[,] _clipElapsedTimes; // [trackIndex, clipIndex]
private bool _hasSoloTracks;
public FeedbackData Data => _data;
public FeedbackPlayerState State => _state;
public float CurrentTime => _currentTime;
public IFeedbackTimeProvider TimeProvider => _timeProvider;
public Transform OwnerTransform => _ownerTransform;
/// <summary>
/// 播放完毕事件。
/// </summary>
public event Action OnComplete;
/// <summary>
/// 被打断事件。
/// </summary>
public event Action OnInterrupt;
/// <summary>
/// 是否已播放完毕(自然结束)。
/// </summary>
public bool IsCompleted => _isCompleted;
/// <summary>
/// 是否处于活跃状态Playing 或 Paused
/// </summary>
public bool IsActive => _state == FeedbackPlayerState.Playing || _state == FeedbackPlayerState.Paused;
public FeedbackPlayer(FeedbackData data, IFeedbackTimeProvider timeProvider, Transform ownerTransform)
{
_data = data;
_timeProvider = timeProvider;
_ownerTransform = ownerTransform;
_state = FeedbackPlayerState.Idle;
_currentTime = 0f;
_isCompleted = false;
}
/// <summary>
/// 开始播放。
/// </summary>
public void Play()
{
if (_data == null)
{
Debug.LogWarning("[FeedbackPlayer] Cannot play: FeedbackData is null.");
return;
}
_currentTime = 0f;
_isCompleted = false;
_state = FeedbackPlayerState.Playing;
InitializeClipStates();
}
/// <summary>
/// 暂停播放。
/// </summary>
public void Pause()
{
if (_state == FeedbackPlayerState.Playing)
{
_state = FeedbackPlayerState.Paused;
}
}
/// <summary>
/// 恢复播放。
/// </summary>
public void Resume()
{
if (_state == FeedbackPlayerState.Paused)
{
_state = FeedbackPlayerState.Playing;
}
}
/// <summary>
/// 立即停止并复位所有已启动的 Action。
/// </summary>
public void Stop()
{
if (_state == FeedbackPlayerState.Idle) return;
InterruptAllActiveClips();
_state = FeedbackPlayerState.Idle;
OnInterrupt?.Invoke();
ClearEvents();
}
/// <summary>
/// 每帧由外部驱动调用FeedbackManager 或 Subcontroller
/// </summary>
public void Tick(float unscaledDeltaTime)
{
if (_state != FeedbackPlayerState.Playing) return;
if (_data == null || _data.tracks == null) return;
float totalDuration = _data.TotalDuration;
// 处理 TotalDuration 为 0 的边界情况
if (totalDuration <= 0f)
{
_state = FeedbackPlayerState.Idle;
_isCompleted = true;
OnComplete?.Invoke();
ClearEvents();
return;
}
List<FeedbackTrack> tracks = _data.tracks;
for (int trackIdx = 0; trackIdx < tracks.Count; trackIdx++)
{
FeedbackTrack track = tracks[trackIdx];
if (!ShouldPlayTrack(track)) continue;
for (int clipIdx = 0; clipIdx < track.clips.Count; clipIdx++)
{
FeedbackClip clip = track.clips[clipIdx];
if (clip?.action == null) continue;
float clipDeltaTime = ComputeClipDeltaTime(clip, unscaledDeltaTime);
ProcessClip(trackIdx, clipIdx, clip, clipDeltaTime);
}
}
_currentTime += unscaledDeltaTime;
if (_currentTime >= totalDuration)
{
_state = FeedbackPlayerState.Idle;
_isCompleted = true;
OnComplete?.Invoke();
ClearEvents();
}
}
/// <summary>
/// 清除所有事件订阅,防止完成/停止后残留引用。
/// </summary>
private void ClearEvents()
{
OnComplete = null;
OnInterrupt = null;
}
/// <summary>
/// 初始化所有 Clip 的运行时状态数组。
/// </summary>
private void InitializeClipStates()
{
List<FeedbackTrack> tracks = _data.tracks;
if (tracks == null || tracks.Count == 0) return;
int maxClips = 0;
_hasSoloTracks = false;
for (int i = 0; i < tracks.Count; i++)
{
if (tracks[i].clips != null && tracks[i].clips.Count > maxClips)
maxClips = tracks[i].clips.Count;
if (tracks[i].solo)
_hasSoloTracks = true;
}
_clipStates = new ClipState[tracks.Count, maxClips];
_clipElapsedTimes = new float[tracks.Count, maxClips];
}
/// <summary>
/// 判断轨道是否应该播放:处理 Mute 和 Solo 逻辑。
/// </summary>
private bool ShouldPlayTrack(FeedbackTrack track)
{
if (track.mute) return false;
if (_hasSoloTracks && !track.solo) return false;
return true;
}
/// <summary>
/// 根据 Clip 的时间设置计算实际 deltaTime。
/// </summary>
private float ComputeClipDeltaTime(FeedbackClip clip, float unscaledDeltaTime)
{
if (_timeProvider == null) return unscaledDeltaTime;
FeedbackTimeSettings settings = clip.overrideTimeSettings
? clip.timeSettings
: _data.defaultTimeSettings;
if (settings == null || !settings.useTimeScale) return unscaledDeltaTime;
return _timeProvider.GetDeltaTime(settings);
}
/// <summary>
/// 处理单个 Clip 的生命周期状态转换和回调调用。
/// </summary>
private void ProcessClip(int trackIdx, int clipIdx, FeedbackClip clip, float deltaTime)
{
ref ClipState clipState = ref _clipStates[trackIdx, clipIdx];
ref float elapsed = ref _clipElapsedTimes[trackIdx, clipIdx];
float safeDuration = Mathf.Max(clip.duration, MIN_DURATION);
switch (clipState)
{
case ClipState.Pending:
if (_currentTime >= clip.startTime)
{
clipState = ClipState.Active;
elapsed = _currentTime - clip.startTime;
FeedbackContext ctx = CreateContext(deltaTime, elapsed, safeDuration);
clip.action.OnStart(ctx);
float normalizedTime = Mathf.Clamp01(elapsed / safeDuration);
clip.action.OnUpdate(CreateContext(deltaTime, elapsed, safeDuration), normalizedTime);
}
break;
case ClipState.Active:
elapsed += deltaTime;
if (elapsed >= safeDuration)
{
elapsed = safeDuration;
clipState = ClipState.Finished;
FeedbackContext ctx = CreateContext(deltaTime, elapsed, safeDuration);
clip.action.OnUpdate(ctx, 1f);
clip.action.OnEnd(ctx);
}
else
{
float normalizedTime = Mathf.Clamp01(elapsed / safeDuration);
FeedbackContext ctx = CreateContext(deltaTime, elapsed, safeDuration);
clip.action.OnUpdate(ctx, normalizedTime);
}
break;
case ClipState.Finished:
break;
}
}
/// <summary>
/// 打断所有已激活的 Clip调用其 OnInterrupt 进行复位。
/// </summary>
private void InterruptAllActiveClips()
{
if (_data?.tracks == null || _clipStates == null) return;
List<FeedbackTrack> tracks = _data.tracks;
for (int trackIdx = 0; trackIdx < tracks.Count; trackIdx++)
{
if (tracks[trackIdx].clips == null) continue;
for (int clipIdx = 0; clipIdx < tracks[trackIdx].clips.Count; clipIdx++)
{
if (_clipStates[trackIdx, clipIdx] == ClipState.Active)
{
FeedbackClip clip = tracks[trackIdx].clips[clipIdx];
if (clip?.action == null) continue;
float elapsed = _clipElapsedTimes[trackIdx, clipIdx];
float safeDuration = Mathf.Max(clip.duration, MIN_DURATION);
FeedbackContext ctx = CreateContext(0f, elapsed, safeDuration);
clip.action.OnInterrupt(ctx);
}
_clipStates[trackIdx, clipIdx] = ClipState.Finished;
}
}
}
/// <summary>
/// 创建 FeedbackContext 实例。
/// </summary>
private FeedbackContext CreateContext(float deltaTime, float elapsedTime, float duration)
{
return new FeedbackContext
{
player = this,
owner = _ownerTransform,
deltaTime = deltaTime,
elapsedTime = elapsedTime,
duration = duration
};
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6f6ececc7f732a54092e62154febce90

View File

@@ -4,7 +4,7 @@ using System.Linq;
using SoftCircuits.Collections;
using UnityEngine;
namespace SLSFramework.General
namespace SLSUtilities.General
{
public static class DictionaryExtension
{

View File

@@ -1,10 +1,10 @@
using System;
using SLSFramework.General;
using SLSUtilities.General;
using UnityEngine;
using UnityEngine.Events;
namespace SLSFramework.General
namespace SLSUtilities.General
{
public class PrioritizedAction : IPrioritized
{

View File

@@ -1,6 +1,6 @@
using System;
namespace SLSFramework.General
namespace SLSUtilities.General
{
/// <summary>
/// 实现该接口的类可以根据优先级进行比较和排序。

View File

@@ -4,7 +4,7 @@ using System.Linq;
using UnityEngine;
using Random = UnityEngine.Random;
namespace SLSFramework.General
namespace SLSUtilities.General
{
public static partial class ListExtension
{

View File

@@ -1,6 +1,6 @@
using UnityEngine;
namespace SLSFramework.General
namespace SLSUtilities.General
{
public class Singleton<T> : MonoBehaviour where T : MonoBehaviour
{

View File

@@ -1,6 +1,6 @@
using UnityEngine;
namespace SLSFramework.General
namespace SLSUtilities.General
{
public static class SpaceConverter
{

View File

@@ -1,6 +1,6 @@
using UnityEngine;
namespace SLSFramework.General
namespace SLSUtilities.General
{
public static class SpriteExtension
{

View File

@@ -3,7 +3,7 @@ using Continentis.MainGame.Card;
using I2.Loc;
using UnityEngine;
namespace SLSFramework.General
namespace SLSUtilities.General
{
public static class StringExtension
{

View File

@@ -1,7 +1,7 @@
using Lean.Pool;
using UnityEngine;
namespace SLSFramework.General
namespace SLSUtilities.General
{
public static class TransformExtension
{

View File

@@ -5,7 +5,7 @@ using NaughtyAttributes;
using UnityEngine;
using UnityEngine.Serialization;
namespace SLSFramework.General.LeanPoolAssistance
namespace SLSUtilities.General.LeanPoolAssistance
{
public class PooledObject : MonoBehaviour, IPoolable
{

View File

@@ -3,7 +3,7 @@ using UnityEditor;
#endif
using UnityEngine;
namespace SLSFramework.General
namespace SLSUtilities.General
{
[System.Serializable]
public class InterfaceHolder<T> where T : class

View File

@@ -2,7 +2,7 @@ using System.Linq;
using System.Collections.Generic;
using UnityEngine;
namespace SLSFramework.General
namespace SLSUtilities.General
{
[System.Serializable]
public class SerializableDictionary<TKey, TValue> : Dictionary<TKey, TValue>, ISerializationCallbackReceiver

View File

@@ -6,7 +6,7 @@ using System.Linq;
using UnityEditor;
using UnityEditorInternal;
namespace SLSFramework.General
namespace SLSUtilities.General
{
[CustomPropertyDrawer(typeof(SerializableDictionary<,>), true)]
public class SerializableDictionaryDrawer : PropertyDrawer
@@ -410,7 +410,7 @@ namespace SLSFramework.General
}
#endif
namespace SLSFramework.General
namespace SLSUtilities.General
{
/// <summary>
/// 用于指定 SerializableDictionary 抽屉中 Key 区域的宽度占比。

View File

@@ -1,6 +1,6 @@
using UnityEngine;
namespace SLSFramework.General
namespace SLSUtilities.General
{
[System.Serializable]
public class UnityObjectWrapper<T> where T : class

View File

@@ -1,8 +1,8 @@
using SLSFramework.StorySystem.Dialog;
using SLSUtilities.StorySystem.Dialog;
using UnityEditor.Experimental.GraphView;
using UnityEngine;
namespace SLSFramework.StorySystem
namespace SLSUtilities.StorySystem
{
// 自定义节点的基础类,用于存储对应的数据
public abstract class BaseGraphNode : Node

View File

@@ -2,7 +2,7 @@ using UnityEditor.Experimental.GraphView;
using UnityEngine;
using UnityEngine.UIElements;
namespace SLSFramework.StorySystem
namespace SLSUtilities.StorySystem
{
public class ConditionGraphNode : BaseGraphNode
{

View File

@@ -2,7 +2,7 @@ using UnityEditor.Experimental.GraphView;
using UnityEngine;
using UnityEngine.UIElements;
namespace SLSFramework.StorySystem
namespace SLSUtilities.StorySystem
{
public class EndGraphNode : BaseGraphNode
{

View File

@@ -2,7 +2,7 @@ using UnityEditor.Experimental.GraphView;
using UnityEngine;
using UnityEngine.UIElements;
namespace SLSFramework.StorySystem.Dialog
namespace SLSUtilities.StorySystem.Dialog
{
public class EventGraphNode : BaseGraphNode
{

View File

@@ -2,7 +2,7 @@ using UnityEditor.Experimental.GraphView;
using UnityEngine;
using UnityEngine.UIElements;
namespace SLSFramework.StorySystem
namespace SLSUtilities.StorySystem
{
public class StartGraphNode : BaseGraphNode
{

View File

@@ -1,7 +1,7 @@
using UnityEditor;
using UnityEngine;
namespace SLSFramework.StorySystem
namespace SLSUtilities.StorySystem
{
public class EditorWindowBase : EditorWindow
{

View File

@@ -6,7 +6,7 @@ using UnityEditor.Experimental.GraphView;
using UnityEngine;
using UnityEngine.UIElements;
namespace SLSFramework.StorySystem
namespace SLSUtilities.StorySystem
{
public partial class GraphViewBase : GraphView
{

View File

@@ -5,7 +5,7 @@ using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;
namespace SLSFramework.StorySystem.Dialog
namespace SLSUtilities.StorySystem.Dialog
{
public class ChoiceGraphNode : BaseGraphNode
{

View File

@@ -3,7 +3,7 @@ using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;
namespace SLSFramework.StorySystem.Dialog
namespace SLSUtilities.StorySystem.Dialog
{
public class CompoundDialogNode : BaseGraphNode
{

View File

@@ -5,7 +5,7 @@ using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;
namespace SLSFramework.StorySystem.Dialog
namespace SLSUtilities.StorySystem.Dialog
{
public class DialogGraphNode : BaseGraphNode
{

View File

@@ -4,7 +4,7 @@ using UnityEngine;
using UnityEditor.UIElements;
using UnityEngine.UIElements;
namespace SLSFramework.StorySystem.Dialog
namespace SLSUtilities.StorySystem.Dialog
{
public class DialogGraphEditorWindow : EditorWindowBase
{

View File

@@ -6,7 +6,7 @@ using UnityEditor.Experimental.GraphView;
using UnityEngine;
using UnityEngine.UIElements;
namespace SLSFramework.StorySystem.Dialog
namespace SLSUtilities.StorySystem.Dialog
{
public partial class DialogGraphView : GraphViewBase
{

View File

@@ -1,11 +1,11 @@
using SLSFramework.StorySystem.Dialog;
using SLSUtilities.StorySystem.Dialog;
using UnityEditor;
using UnityEditor.Experimental.GraphView;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;
namespace SLSFramework.StorySystem.Storyline
namespace SLSUtilities.StorySystem.Storyline
{
public class StorylineDialogNode : BaseGraphNode
{

View File

@@ -4,7 +4,7 @@ using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;
namespace SLSFramework.StorySystem.Storyline
namespace SLSUtilities.StorySystem.Storyline
{
public class StorylineGraphEditorWindow : EditorWindowBase
{

View File

@@ -1,13 +1,13 @@
using System;
using System.Collections.Generic;
using System.Linq;
using SLSFramework.StorySystem.Dialog;
using SLSUtilities.StorySystem.Dialog;
using UnityEditor;
using UnityEditor.Experimental.GraphView;
using UnityEngine;
using UnityEngine.UIElements;
namespace SLSFramework.StorySystem.Storyline
namespace SLSUtilities.StorySystem.Storyline
{
public class StorylineGraphView : GraphViewBase
{

View File

@@ -1,7 +1,7 @@
using System.Collections.Generic;
using UnityEngine;
namespace SLSFramework.StorySystem.Dialog
namespace SLSUtilities.StorySystem.Dialog
{
[CreateAssetMenu(fileName = "NewDialogueGraph", menuName = "StorySystem/Dialog/Dialog Graph")]
public class DialogGraph : GraphBase

View File

@@ -3,7 +3,7 @@ using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Serialization;
namespace SLSFramework.StorySystem.Dialog
namespace SLSUtilities.StorySystem.Dialog
{
// ----------------------------------------------------------------------
// 各种具体节点的数据

View File

@@ -2,7 +2,7 @@ using System;
using System.Collections.Generic;
using UnityEngine;
namespace SLSFramework.StorySystem
namespace SLSUtilities.StorySystem
{
[CreateAssetMenu(fileName = "NewCharacterData", menuName = "StorySystem/Character Data")]
public partial class StoryCharacterData : ScriptableObject

View File

@@ -1,6 +1,6 @@
using UnityEngine;
namespace SLSFramework.StorySystem
namespace SLSUtilities.StorySystem
{
using System;
using UnityEngine;

View File

@@ -1,7 +1,7 @@
using System.Collections.Generic;
using UnityEngine;
namespace SLSFramework.StorySystem.Storyline
namespace SLSUtilities.StorySystem.Storyline
{
[CreateAssetMenu(fileName = "NewStorylineGraph", menuName = "StorySystem/Storyline/Storyline Graph")]
public class StorylineGraph : GraphBase

View File

@@ -1,8 +1,8 @@
using System;
using SLSFramework.StorySystem.Dialog;
using SLSUtilities.StorySystem.Dialog;
using UnityEngine;
namespace SLSFramework.StorySystem.Storyline
namespace SLSUtilities.StorySystem.Storyline
{
[Serializable]
public class StorylineDialogNodeData : BaseNodeData

View File

@@ -1,7 +1,7 @@
using System.Collections.Generic;
using UnityEngine;
namespace SLSFramework.StorySystem
namespace SLSUtilities.StorySystem
{
public abstract class GraphBase : ScriptableObject
{

View File

@@ -10,7 +10,7 @@ using UnityEngine;
using UnityEngine.Events;
using Object = UnityEngine.Object;
namespace SLSFramework.UModAssistance
namespace SLSUtilities.UModAssistance
{
#region List<string>

View File

@@ -8,13 +8,13 @@ using Continentis.MainGame.Equipment;
using Continentis.MainGame.Rules;
using Continentis.Mods;
using I2.Loc;
using SLSFramework.General;
using SLSUtilities.General;
using UMod;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.UI;
namespace SLSFramework.UModAssistance
namespace SLSUtilities.UModAssistance
{
public partial class ModBrowser : MonoBehaviour
{

View File

@@ -4,7 +4,7 @@ using UMod;
using UnityEngine;
using UnityEngine.UI;
namespace SLSFramework.UModAssistance
namespace SLSUtilities.UModAssistance
{
public class ModLoadTab : MonoBehaviour
{

View File

@@ -4,13 +4,13 @@ using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using SLSFramework.General;
using SLSUtilities.General;
using UMod;
using UMod.Scripting;
using UnityEngine;
using Object = UnityEngine.Object;
namespace SLSFramework.UModAssistance
namespace SLSUtilities.UModAssistance
{
public static partial class ModManager
{