From 0dc58894176ef0300d07195efd333e77ce260dea Mon Sep 17 00:00:00 2001 From: basil <2428390463@qq.com> Date: Thu, 18 Jun 2026 22:50:46 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AF=BC=E5=85=A5=20TimerModule=20=E8=AE=A1?= =?UTF-8?q?=E6=97=B6=E5=99=A8=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Scripts/Runtime/Base/GameEntry.Custom.cs | 6 +- Assets/Launcher.unity | 45 ++ Assets/Plugins/TimerModule.meta | 8 + Assets/Plugins/TimerModule/Editor.meta | 8 + .../Editor/SepCore.Timer.Editor.asmdef | 19 + .../Editor/SepCore.Timer.Editor.asmdef.meta | 7 + .../Editor/TimerComponentEditor.cs | 249 +++++++ .../Editor/TimerComponentEditor.cs.meta | 11 + Assets/Plugins/TimerModule/Runtime.meta | 8 + .../Runtime/SepCore.Runtime.Timer.asmref | 3 + .../Runtime/SepCore.Runtime.Timer.asmref.meta | 7 + .../TimerModule/Runtime/TimerComponent.cs | 622 ++++++++++++++++++ .../Runtime/TimerComponent.cs.meta | 11 + .../TimerModule/Runtime/TimerHandle.cs | 55 ++ .../TimerModule/Runtime/TimerHandle.cs.meta | 11 + .../Plugins/TimerModule/Runtime/TimerTask.cs | 75 +++ .../TimerModule/Runtime/TimerTask.cs.meta | 11 + .../TimerModule/Runtime/TimerTimeMode.cs | 18 + .../TimerModule/Runtime/TimerTimeMode.cs.meta | 11 + Assets/Plugins/TimerModule/接入说明.md | 143 ++++ Assets/Plugins/TimerModule/接入说明.md.meta | 7 + 21 files changed, 1334 insertions(+), 1 deletion(-) create mode 100644 Assets/Plugins/TimerModule.meta create mode 100644 Assets/Plugins/TimerModule/Editor.meta create mode 100644 Assets/Plugins/TimerModule/Editor/SepCore.Timer.Editor.asmdef create mode 100644 Assets/Plugins/TimerModule/Editor/SepCore.Timer.Editor.asmdef.meta create mode 100644 Assets/Plugins/TimerModule/Editor/TimerComponentEditor.cs create mode 100644 Assets/Plugins/TimerModule/Editor/TimerComponentEditor.cs.meta create mode 100644 Assets/Plugins/TimerModule/Runtime.meta create mode 100644 Assets/Plugins/TimerModule/Runtime/SepCore.Runtime.Timer.asmref create mode 100644 Assets/Plugins/TimerModule/Runtime/SepCore.Runtime.Timer.asmref.meta create mode 100644 Assets/Plugins/TimerModule/Runtime/TimerComponent.cs create mode 100644 Assets/Plugins/TimerModule/Runtime/TimerComponent.cs.meta create mode 100644 Assets/Plugins/TimerModule/Runtime/TimerHandle.cs create mode 100644 Assets/Plugins/TimerModule/Runtime/TimerHandle.cs.meta create mode 100644 Assets/Plugins/TimerModule/Runtime/TimerTask.cs create mode 100644 Assets/Plugins/TimerModule/Runtime/TimerTask.cs.meta create mode 100644 Assets/Plugins/TimerModule/Runtime/TimerTimeMode.cs create mode 100644 Assets/Plugins/TimerModule/Runtime/TimerTimeMode.cs.meta create mode 100644 Assets/Plugins/TimerModule/接入说明.md create mode 100644 Assets/Plugins/TimerModule/接入说明.md.meta diff --git a/Assets/GameMain/Scripts/Runtime/Base/GameEntry.Custom.cs b/Assets/GameMain/Scripts/Runtime/Base/GameEntry.Custom.cs index 141a42d..6f556c1 100644 --- a/Assets/GameMain/Scripts/Runtime/Base/GameEntry.Custom.cs +++ b/Assets/GameMain/Scripts/Runtime/Base/GameEntry.Custom.cs @@ -7,6 +7,7 @@ using SepCore.CameraModule; using SepCore.SpriteCache; using SepCore.UIRouter; using SepCore.Simulation; +using SepCore.Timer; /// /// 游戏入口。 @@ -26,11 +27,13 @@ public partial class GameEntry public static SpriteCacheComponent SpriteCache { get; private set; } public static UIRouterComponent UIRouter { get; private set; } - + public static InputModuleComponent InputModule { get; private set; } public static CameraModuleComponent CameraModule { get; private set; } + public static TimerComponent Timer { get; private set; } + private static void InitCustomComponents() { BuiltinData = UnityGameFramework.Runtime.GameEntry.GetComponent(); @@ -42,5 +45,6 @@ public partial class GameEntry UIRouter = UnityGameFramework.Runtime.GameEntry.GetComponent(); InputModule = UnityGameFramework.Runtime.GameEntry.GetComponent(); CameraModule = UnityGameFramework.Runtime.GameEntry.GetComponent(); + Timer = UnityGameFramework.Runtime.GameEntry.GetComponent(); } } diff --git a/Assets/Launcher.unity b/Assets/Launcher.unity index c85d94d..725f25a 100644 --- a/Assets/Launcher.unity +++ b/Assets/Launcher.unity @@ -245,6 +245,7 @@ Transform: - {fileID: 1652245191} - {fileID: 1245211031} - {fileID: 1170305582} + - {fileID: 1852433568} m_Father: {fileID: 1852670053} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &120093239 @@ -1423,6 +1424,50 @@ MonoBehaviour: ProjectileHitPresentationEffectTypeId: 0 _targetSelectionSettings: CellSize: 2 +--- !u!1 &1852433567 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1852433568} + - component: {fileID: 1852433569} + m_Layer: 0 + m_Name: Timer + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &1852433568 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1852433567} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 119167776} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &1852433569 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1852433567} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 86583a9107c4c3b45a888f4cc47c2856, type: 3} + m_Name: + m_EditorClassIdentifier: --- !u!1 &1852670052 GameObject: m_ObjectHideFlags: 0 diff --git a/Assets/Plugins/TimerModule.meta b/Assets/Plugins/TimerModule.meta new file mode 100644 index 0000000..0802f87 --- /dev/null +++ b/Assets/Plugins/TimerModule.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f47e8e71c7cdc5743a416147e47c5d0a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/TimerModule/Editor.meta b/Assets/Plugins/TimerModule/Editor.meta new file mode 100644 index 0000000..c88c5b8 --- /dev/null +++ b/Assets/Plugins/TimerModule/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b96faa6532eb3314597c8e167cfd20ea +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/TimerModule/Editor/SepCore.Timer.Editor.asmdef b/Assets/Plugins/TimerModule/Editor/SepCore.Timer.Editor.asmdef new file mode 100644 index 0000000..a658c6d --- /dev/null +++ b/Assets/Plugins/TimerModule/Editor/SepCore.Timer.Editor.asmdef @@ -0,0 +1,19 @@ +{ + "name": "SepCore.Timer.Editor", + "rootNamespace": "SepCore.Timer", + "references": [ + "GUID:363c5eb08ff8e6a439b85e37b8c20d96", + "GUID:436e23dbdc31e7d4fb5c3f804548b2df" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} \ No newline at end of file diff --git a/Assets/Plugins/TimerModule/Editor/SepCore.Timer.Editor.asmdef.meta b/Assets/Plugins/TimerModule/Editor/SepCore.Timer.Editor.asmdef.meta new file mode 100644 index 0000000..a8da7b3 --- /dev/null +++ b/Assets/Plugins/TimerModule/Editor/SepCore.Timer.Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 2703d0b6ecf886044905648043ceb65d +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/TimerModule/Editor/TimerComponentEditor.cs b/Assets/Plugins/TimerModule/Editor/TimerComponentEditor.cs new file mode 100644 index 0000000..e3af946 --- /dev/null +++ b/Assets/Plugins/TimerModule/Editor/TimerComponentEditor.cs @@ -0,0 +1,249 @@ +using System; +using UnityEditor; +using UnityEngine; + +namespace SepCore.Timer.Editor +{ + [CustomEditor(typeof(TimerComponent))] + public sealed class TimerComponentEditor : UnityEditor.Editor + { + private TimerComponent _timer; + private Vector2 _scrollPosition; + private bool _showDebugControls = true; + + private void OnEnable() + { + _timer = (TimerComponent)target; + EditorApplication.update += Repaint; + } + + private void OnDisable() + { + EditorApplication.update -= Repaint; + } + + public override void OnInspectorGUI() + { + base.OnInspectorGUI(); + + if (!Application.isPlaying) + { + EditorGUILayout.HelpBox("运行时显示任务列表和调试信息", MessageType.Info); + return; + } + + DrawStatistics(); + DrawDebugControls(); + DrawTaskList(); + } + + private void DrawStatistics() + { + TimerTaskSnapshot[] tasks = _timer.GetTaskSnapshots(); + + EditorGUILayout.Space(); + EditorGUILayout.LabelField("统计信息", EditorStyles.boldLabel); + + using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox)) + { + int scaledCount = 0; + int unscaledCount = 0; + int errorCount = 0; + int infiniteCount = 0; + + foreach (TimerTaskSnapshot task in tasks) + { + if (task.TimeMode == TimerTimeMode.Scaled) + scaledCount++; + else + unscaledCount++; + + if (task.HasError) + errorCount++; + + if (task.RemainingRepeatCount < 0) + infiniteCount++; + } + + using (new EditorGUILayout.HorizontalScope()) + { + EditorGUILayout.LabelField("活跃任务", $"{tasks.Length}", GUILayout.Width(120)); + EditorGUILayout.LabelField("Scaled", $"{scaledCount}", GUILayout.Width(80)); + EditorGUILayout.LabelField("Unscaled", $"{unscaledCount}", GUILayout.Width(80)); + } + + using (new EditorGUILayout.HorizontalScope()) + { + EditorGUILayout.LabelField("无限循环", $"{infiniteCount}", GUILayout.Width(120)); + EditorGUILayout.LabelField("异常任务", $"{errorCount}", errorCount > 0 ? GetErrorLabelStyle() : GUIStyle.none, GUILayout.Width(80)); + EditorGUILayout.LabelField("全局状态", _timer.IsPaused ? "已暂停" : "运行中", GetRichLabelStyle()); + } + } + } + + private void DrawDebugControls() + { + EditorGUILayout.Space(); + _showDebugControls = EditorGUILayout.Foldout(_showDebugControls, "调试控制", true); + + if (!_showDebugControls) + return; + + using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox)) + { + using (new EditorGUILayout.HorizontalScope()) + { + if (GUILayout.Button(_timer.IsPaused ? "恢复全部" : "暂停全部", GUILayout.Height(25))) + { + if (_timer.IsPaused) + _timer.Resume(); + else + _timer.Pause(); + } + + if (GUILayout.Button("清理全部", GUILayout.Height(25))) + { + if (EditorUtility.DisplayDialog("确认", "确定要清除所有定时任务吗?", "确定", "取消")) + { + _timer.ClearAll(); + } + } + } + } + } + + private void DrawTaskList() + { + TimerTaskSnapshot[] tasks = _timer.GetTaskSnapshots(); + + EditorGUILayout.Space(); + EditorGUILayout.LabelField($"活跃任务列表 ({tasks.Length})", EditorStyles.boldLabel); + + if (tasks.Length == 0) + { + EditorGUILayout.HelpBox("当前没有活跃的定时任务", MessageType.Info); + return; + } + + _scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition); + + foreach (TimerTaskSnapshot task in tasks) + { + DrawTaskItem(task); + } + + EditorGUILayout.EndScrollView(); + } + + private void DrawTaskItem(TimerTaskSnapshot task) + { + Color originalColor = GUI.backgroundColor; + + if (task.HasError) + GUI.backgroundColor = new Color(1f, 0.3f, 0.3f, 0.3f); + + using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox)) + { + if (task.HasError) + { + EditorGUILayout.LabelField("⚠ 该任务回调曾抛出异常", GetErrorLabelStyle()); + } + + // 头部:ID + 时间模式 + 取消按钮 + using (new EditorGUILayout.HorizontalScope()) + { + EditorGUILayout.LabelField($"ID: #{task.Id}", GUILayout.Width(60)); + + string modeLabel = task.TimeMode == TimerTimeMode.Scaled ? "Scaled" : "Unscaled"; + Color modeColor = task.TimeMode == TimerTimeMode.Scaled ? Color.cyan : Color.yellow; + EditorGUILayout.LabelField($"{modeLabel}", GetRichLabelStyle(), GUILayout.Width(60)); + + string repeatLabel = task.RemainingRepeatCount < 0 ? "∞" : $"{task.RemainingRepeatCount}"; + EditorGUILayout.LabelField($"剩余: {repeatLabel} 次", GUILayout.Width(80)); + + GUILayout.FlexibleSpace(); + + if (GUILayout.Button("取消", GUILayout.Width(50))) + { + _timer.Cancel(new TimerHandle(task.Id)); + } + } + + // 进度条 + DrawProgressBar(task); + + // 回调信息 + EditorGUILayout.LabelField("回调:", task.CallbackMethod, EditorStyles.miniLabel); + + // 归属对象(可点击 Ping) + using (new EditorGUILayout.HorizontalScope()) + { + EditorGUILayout.LabelField("归属:", GUILayout.Width(40)); + if (task.Owner is UnityEngine.Object unityObject && unityObject != null) + { + if (GUILayout.Button(unityObject.name, EditorStyles.linkLabel)) + { + EditorGUIUtility.PingObject(unityObject); + Selection.activeObject = unityObject; + } + } + else if (task.Owner != null) + { + EditorGUILayout.LabelField(task.Owner.ToString(), EditorStyles.miniLabel); + } + else + { + EditorGUILayout.LabelField("(无归属对象)", EditorStyles.miniLabel); + } + } + } + + GUI.backgroundColor = originalColor; + EditorGUILayout.Space(2); + } + + private void DrawProgressBar(TimerTaskSnapshot task) + { + float progress; + string label; + + if (task.Interval <= 0f || task.RemainingRepeatCount == 1) + { + // 一次性任务:显示延迟进度 + float totalTime = Mathf.Max(task.Interval, task.RemainingTime); + progress = totalTime > 0f ? 1f - (task.RemainingTime / totalTime) : 1f; + label = $"剩余 {task.RemainingTime:F2}s (一次性)"; + } + else if (task.RemainingRepeatCount < 0) + { + // 无限循环:显示当前周期进度 + progress = 1f - (task.RemainingTime / task.Interval); + label = $"剩余 {task.RemainingTime:F2}s (无限循环)"; + } + else + { + // 有限循环:显示当前周期进度 + progress = 1f - (task.RemainingTime / task.Interval); + label = $"剩余 {task.RemainingTime:F2}s (周期进度)"; + } + + Rect rect = EditorGUILayout.GetControlRect(false, 18); + EditorGUI.ProgressBar(rect, Mathf.Clamp01(progress), label); + } + + private static GUIStyle GetRichLabelStyle() + { + GUIStyle style = new GUIStyle(EditorStyles.label); + style.richText = true; + return style; + } + + private static GUIStyle GetErrorLabelStyle() + { + GUIStyle style = new GUIStyle(EditorStyles.label); + style.normal.textColor = Color.red; + style.fontStyle = FontStyle.Bold; + return style; + } + } +} diff --git a/Assets/Plugins/TimerModule/Editor/TimerComponentEditor.cs.meta b/Assets/Plugins/TimerModule/Editor/TimerComponentEditor.cs.meta new file mode 100644 index 0000000..3f13412 --- /dev/null +++ b/Assets/Plugins/TimerModule/Editor/TimerComponentEditor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e536163046bf5ff40bd04430d3a45af5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/TimerModule/Runtime.meta b/Assets/Plugins/TimerModule/Runtime.meta new file mode 100644 index 0000000..0217cf8 --- /dev/null +++ b/Assets/Plugins/TimerModule/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: dfd4cfbe4e915a3479007a9ed6f8539e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/TimerModule/Runtime/SepCore.Runtime.Timer.asmref b/Assets/Plugins/TimerModule/Runtime/SepCore.Runtime.Timer.asmref new file mode 100644 index 0000000..a76e3b8 --- /dev/null +++ b/Assets/Plugins/TimerModule/Runtime/SepCore.Runtime.Timer.asmref @@ -0,0 +1,3 @@ +{ + "reference": "GUID:436e23dbdc31e7d4fb5c3f804548b2df" +} \ No newline at end of file diff --git a/Assets/Plugins/TimerModule/Runtime/SepCore.Runtime.Timer.asmref.meta b/Assets/Plugins/TimerModule/Runtime/SepCore.Runtime.Timer.asmref.meta new file mode 100644 index 0000000..a288a29 --- /dev/null +++ b/Assets/Plugins/TimerModule/Runtime/SepCore.Runtime.Timer.asmref.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 4fad44cf84287fc45a187478fb6c9891 +AssemblyDefinitionReferenceImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/TimerModule/Runtime/TimerComponent.cs b/Assets/Plugins/TimerModule/Runtime/TimerComponent.cs new file mode 100644 index 0000000..e70a4e9 --- /dev/null +++ b/Assets/Plugins/TimerModule/Runtime/TimerComponent.cs @@ -0,0 +1,622 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using UnityGameFramework.Runtime; + +namespace SepCore.Timer +{ + /// + /// 通用定时器组件。 + /// + [DisallowMultipleComponent] + [AddComponentMenu("Game Main/Timer")] + public sealed class TimerComponent : GameFrameworkComponent + { + private readonly List _timerTasks = new List(); + private bool _isPaused = false; + private bool _isUpdating = false; + private int _serialId = 0; + + /// + /// 获取当前定时任务数量。 + /// + public int Count => _timerTasks.Count; + + /// + /// 获取定时器是否暂停。 + /// + public bool IsPaused => _isPaused; + + private void Update() + { + if (_isPaused) + { + return; + } + + UpdateTasks(Time.deltaTime, Time.unscaledDeltaTime); + } + + private void OnDestroy() + { + ClearAll(); + } + + /// + /// 创建定时任务。 + /// + /// 定时任务描述。 + /// 定时任务句柄。 + public TimerHandle Schedule(TimerTask timerTask) + { + if (timerTask.Callback == null) + { + Log.Error("Timer task callback is invalid."); + return TimerHandle.Invalid; + } + + if (timerTask.RepeatCount == 0) + { + return TimerHandle.Invalid; + } + + float delay = Mathf.Max(0f, timerTask.Delay); + float interval = Mathf.Max(0f, timerTask.Interval); + if (timerTask.RepeatCount != 1 && interval <= 0f) + { + Log.Error("Timer task interval must be greater than zero for repeated tasks."); + return TimerHandle.Invalid; + } + + int id = GenerateSerialId(); + TimerTaskInfo taskInfo = new TimerTaskInfo(id, delay, interval, timerTask.RepeatCount, + timerTask.TimeMode, timerTask.Owner, timerTask.Callback); + _timerTasks.Add(taskInfo); + return new TimerHandle(id); + } + + /// + /// 创建一次性定时任务。 + /// + public TimerHandle ScheduleOnce(float delay, Action callback, object owner = null, + TimerTimeMode timeMode = TimerTimeMode.Scaled) + { + return Schedule(new TimerTask(delay, 0f, 1, callback, owner, timeMode)); + } + + /// + /// 创建循环定时任务,首次触发延迟等于循环间隔。 + /// + public TimerHandle ScheduleRepeat(float interval, Action callback, int repeatCount = -1, object owner = null, + TimerTimeMode timeMode = TimerTimeMode.Scaled) + { + return ScheduleRepeat(interval, interval, callback, repeatCount, owner, timeMode); + } + + /// + /// 创建循环定时任务。 + /// + public TimerHandle ScheduleRepeat(float delay, float interval, Action callback, int repeatCount = -1, + object owner = null, TimerTimeMode timeMode = TimerTimeMode.Scaled) + { + return Schedule(new TimerTask(delay, interval, repeatCount, callback, owner, timeMode)); + } + + /// + /// 取消定时任务。 + /// + public bool Cancel(TimerHandle handle) + { + if (!handle.IsValid) + { + return false; + } + + for (int i = _timerTasks.Count - 1; i >= 0; i--) + { + TimerTaskInfo taskInfo = _timerTasks[i]; + if (taskInfo.Id != handle.Id) + { + continue; + } + + taskInfo.IsCancelled = true; + if (!_isUpdating) + { + _timerTasks.RemoveAt(i); + } + + return true; + } + + return false; + } + + /// + /// 取消归属于指定对象的全部定时任务。 + /// + public int CancelByOwner(object owner) + { + if (owner == null) + { + return 0; + } + + int count = 0; + for (int i = _timerTasks.Count - 1; i >= 0; i--) + { + TimerTaskInfo taskInfo = _timerTasks[i]; + if (!ReferenceEquals(taskInfo.Owner, owner)) + { + continue; + } + + taskInfo.IsCancelled = true; + count++; + if (!_isUpdating) + { + _timerTasks.RemoveAt(i); + } + } + + return count; + } + + /// + /// 暂停所有定时任务。 + /// + public void Pause() + { + _isPaused = true; + } + + /// + /// 恢复所有定时任务。 + /// + public void Resume() + { + _isPaused = false; + } + + /// + /// 清理所有定时任务。 + /// + public void ClearAll() + { + if (!_isUpdating) + { + _timerTasks.Clear(); + return; + } + + for (int i = 0; i < _timerTasks.Count; i++) + { + _timerTasks[i].IsCancelled = true; + } + } + + /// + /// 获取所有活跃任务的快照(调试用)。 + /// + public TimerTaskSnapshot[] GetTaskSnapshots() + { + List snapshots = new List(_timerTasks.Count); + foreach (TimerTaskInfo taskInfo in _timerTasks) + { + if (taskInfo.IsCancelled) + { + continue; + } + + snapshots.Add(new TimerTaskSnapshot + { + Id = taskInfo.Id, + RemainingTime = taskInfo.RemainingTime, + Interval = taskInfo.Interval, + RemainingRepeatCount = taskInfo.RemainingRepeatCount, + TimeMode = taskInfo.TimeMode, + Owner = taskInfo.Owner, + CallbackMethod = $"{taskInfo.Callback.Method.DeclaringType?.Name}.{taskInfo.Callback.Method.Name}", + HasError = taskInfo.HasError, + IsPaused = taskInfo.IsPaused + }); + } + + return snapshots.ToArray(); + } + + /// + /// 设置任务的循环间隔。 + /// + /// 任务句柄。 + /// 新的间隔时间(秒)。 + /// 是否根据新旧间隔的比例调整当前剩余时间。 + /// 是否设置成功。 + public bool SetInterval(TimerHandle handle, float newInterval, bool adjustRemainingTime = false) + { + if (!handle.IsValid) + { + return false; + } + + for (int i = 0; i < _timerTasks.Count; i++) + { + TimerTaskInfo taskInfo = _timerTasks[i]; + if (taskInfo.Id != handle.Id || taskInfo.IsCancelled) + { + continue; + } + + float safeInterval = Mathf.Max(0f, newInterval); + if (adjustRemainingTime && taskInfo.Interval > 0f && safeInterval > 0f) + { + float ratio = safeInterval / taskInfo.Interval; + taskInfo.RemainingTime *= ratio; + } + + taskInfo.Interval = safeInterval; + return true; + } + + return false; + } + + /// + /// 暂停单个任务。 + /// + /// 任务句柄。 + /// 是否暂停成功。 + public bool PauseTask(TimerHandle handle) + { + if (!handle.IsValid) + { + return false; + } + + for (int i = 0; i < _timerTasks.Count; i++) + { + TimerTaskInfo taskInfo = _timerTasks[i]; + if (taskInfo.Id != handle.Id) + { + continue; + } + + taskInfo.IsPaused = true; + return true; + } + + return false; + } + + /// + /// 恢复单个任务。 + /// + /// 任务句柄。 + /// 是否恢复成功。 + public bool ResumeTask(TimerHandle handle) + { + if (!handle.IsValid) + { + return false; + } + + for (int i = 0; i < _timerTasks.Count; i++) + { + TimerTaskInfo taskInfo = _timerTasks[i]; + if (taskInfo.Id != handle.Id) + { + continue; + } + + taskInfo.IsPaused = false; + return true; + } + + return false; + } + + /// + /// 获取任务的剩余时间。 + /// + /// 任务句柄。 + /// 剩余时间(秒),任务不存在返回 -1f。 + public float GetRemainingTime(TimerHandle handle) + { + if (!handle.IsValid) + { + return -1f; + } + + for (int i = 0; i < _timerTasks.Count; i++) + { + TimerTaskInfo taskInfo = _timerTasks[i]; + if (taskInfo.Id == handle.Id && !taskInfo.IsCancelled) + { + return taskInfo.RemainingTime; + } + } + + return -1f; + } + + /// + /// 获取任务的循环间隔。 + /// + /// 任务句柄。 + /// 循环间隔(秒),任务不存在返回 -1f。 + public float GetInterval(TimerHandle handle) + { + if (!handle.IsValid) + { + return -1f; + } + + for (int i = 0; i < _timerTasks.Count; i++) + { + TimerTaskInfo taskInfo = _timerTasks[i]; + if (taskInfo.Id == handle.Id && !taskInfo.IsCancelled) + { + return taskInfo.Interval; + } + } + + return -1f; + } + + /// + /// 获取任务的剩余重复次数。 + /// + /// 任务句柄。 + /// 剩余重复次数,任务不存在返回 -1。 + public int GetRemainingRepeatCount(TimerHandle handle) + { + if (!handle.IsValid) + { + return -1; + } + + for (int i = 0; i < _timerTasks.Count; i++) + { + TimerTaskInfo taskInfo = _timerTasks[i]; + if (taskInfo.Id == handle.Id && !taskInfo.IsCancelled) + { + return taskInfo.RemainingRepeatCount; + } + } + + return -1; + } + + /// + /// 重置任务的剩余时间。 + /// + /// 任务句柄。 + /// 新的剩余时间(秒)。 + /// 是否重置成功。 + public bool ResetRemainingTime(TimerHandle handle, float newRemainingTime) + { + if (!handle.IsValid) + { + return false; + } + + for (int i = 0; i < _timerTasks.Count; i++) + { + TimerTaskInfo taskInfo = _timerTasks[i]; + if (taskInfo.Id == handle.Id && !taskInfo.IsCancelled) + { + taskInfo.RemainingTime = Mathf.Max(0f, newRemainingTime); + return true; + } + } + + return false; + } + + /// + /// 任务是否已暂停。 + /// + /// 任务句柄。 + /// 任务是否已暂停,任务不存在返回 false。 + public bool IsTaskPaused(TimerHandle handle) + { + if (!handle.IsValid) + { + return false; + } + + for (int i = 0; i < _timerTasks.Count; i++) + { + TimerTaskInfo taskInfo = _timerTasks[i]; + if (taskInfo.Id == handle.Id && !taskInfo.IsCancelled) + { + return taskInfo.IsPaused; + } + } + + return false; + } + + private void UpdateTasks(float scaledDeltaTime, float unscaledDeltaTime) + { + int taskCount = _timerTasks.Count; + _isUpdating = true; + for (int i = taskCount - 1; i >= 0; i--) + { + if (i >= _timerTasks.Count) + { + continue; + } + + TimerTaskInfo taskInfo = _timerTasks[i]; + if (taskInfo.IsCancelled || taskInfo.IsPaused) + { + continue; + } + + float deltaTime = taskInfo.TimeMode == TimerTimeMode.Unscaled ? unscaledDeltaTime : scaledDeltaTime; + UpdateTask(taskInfo, deltaTime); + } + + _isUpdating = false; + RemoveCancelledTasks(); + } + + private void UpdateTask(TimerTaskInfo taskInfo, float deltaTime) + { + if (deltaTime <= 0f) + { + return; + } + + taskInfo.RemainingTime -= deltaTime; + if (taskInfo.RemainingTime > 0f) + { + return; + } + + InvokeCallback(taskInfo); + if (taskInfo.IsCancelled) + { + return; + } + + if (taskInfo.RemainingRepeatCount > 0) + { + taskInfo.RemainingRepeatCount--; + } + + if (taskInfo.RemainingRepeatCount == 0) + { + taskInfo.IsCancelled = true; + return; + } + + taskInfo.RemainingTime = taskInfo.Interval; + } + + private void InvokeCallback(TimerTaskInfo taskInfo) + { + try + { + taskInfo.Callback.Invoke(); + } + catch (Exception exception) + { + taskInfo.HasError = true; + Log.Error("Timer task callback exception: {0}", exception); + } + } + + private void RemoveCancelledTasks() + { + for (int i = _timerTasks.Count - 1; i >= 0; i--) + { + if (_timerTasks[i].IsCancelled) + { + _timerTasks.RemoveAt(i); + } + } + } + + private int GenerateSerialId() + { + _serialId++; + if (_serialId <= 0) + { + _serialId = 1; + } + + return _serialId; + } + + private sealed class TimerTaskInfo + { + public TimerTaskInfo(int id, float delay, float interval, int repeatCount, TimerTimeMode timeMode, + object owner, Action callback) + { + Id = id; + RemainingTime = delay; + Interval = interval; + RemainingRepeatCount = repeatCount; + TimeMode = timeMode; + Owner = owner; + Callback = callback; + IsCancelled = false; + IsPaused = false; + } + + public int Id + { + get; + } + + public float RemainingTime + { + get; + set; + } + + public float Interval + { + get; + set; + } + + public int RemainingRepeatCount + { + get; + set; + } + + public TimerTimeMode TimeMode + { + get; + } + + public object Owner + { + get; + } + + public Action Callback + { + get; + } + + public bool IsCancelled + { + get; + set; + } + + public bool IsPaused + { + get; + set; + } + + public bool HasError + { + get; + set; + } + } + } + + /// + /// 定时任务快照(调试用)。 + /// + public struct TimerTaskSnapshot + { + public int Id; + public float RemainingTime; + public float Interval; + public int RemainingRepeatCount; + public TimerTimeMode TimeMode; + public object Owner; + public string CallbackMethod; + public bool HasError; + public bool IsPaused; + } +} diff --git a/Assets/Plugins/TimerModule/Runtime/TimerComponent.cs.meta b/Assets/Plugins/TimerModule/Runtime/TimerComponent.cs.meta new file mode 100644 index 0000000..ee8d47b --- /dev/null +++ b/Assets/Plugins/TimerModule/Runtime/TimerComponent.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 86583a9107c4c3b45a888f4cc47c2856 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/TimerModule/Runtime/TimerHandle.cs b/Assets/Plugins/TimerModule/Runtime/TimerHandle.cs new file mode 100644 index 0000000..9c2be2d --- /dev/null +++ b/Assets/Plugins/TimerModule/Runtime/TimerHandle.cs @@ -0,0 +1,55 @@ +using System; + +namespace SepCore.Timer +{ + /// + /// 定时任务句柄。 + /// + public readonly struct TimerHandle : IEquatable + { + public static readonly TimerHandle Invalid = new TimerHandle(0); + + public TimerHandle(int id) + { + Id = id; + } + + /// + /// 获取定时任务编号。 + /// + public int Id + { + get; + } + + /// + /// 获取句柄是否有效。 + /// + public bool IsValid => Id > 0; + + public bool Equals(TimerHandle other) + { + return Id == other.Id; + } + + public override bool Equals(object obj) + { + return obj is TimerHandle other && Equals(other); + } + + public override int GetHashCode() + { + return Id; + } + + public static bool operator ==(TimerHandle left, TimerHandle right) + { + return left.Equals(right); + } + + public static bool operator !=(TimerHandle left, TimerHandle right) + { + return !left.Equals(right); + } + } +} diff --git a/Assets/Plugins/TimerModule/Runtime/TimerHandle.cs.meta b/Assets/Plugins/TimerModule/Runtime/TimerHandle.cs.meta new file mode 100644 index 0000000..f7659b1 --- /dev/null +++ b/Assets/Plugins/TimerModule/Runtime/TimerHandle.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d24d22014412ca54eaee690f92779349 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/TimerModule/Runtime/TimerTask.cs b/Assets/Plugins/TimerModule/Runtime/TimerTask.cs new file mode 100644 index 0000000..c25836f --- /dev/null +++ b/Assets/Plugins/TimerModule/Runtime/TimerTask.cs @@ -0,0 +1,75 @@ +using System; + +namespace SepCore.Timer +{ + /// + /// 定时任务描述。 + /// + public struct TimerTask + { + public TimerTask(float delay, float interval, int repeatCount, Action callback, object owner = null, + TimerTimeMode timeMode = TimerTimeMode.Scaled) + { + Delay = delay; + Interval = interval; + RepeatCount = repeatCount; + Callback = callback; + Owner = owner; + TimeMode = timeMode; + } + + /// + /// 获取或设置首次触发前的延迟时间(秒)。 + /// + public float Delay + { + get; + set; + } + + /// + /// 获取或设置循环触发间隔(秒)。一次性任务会忽略该值。 + /// + public float Interval + { + get; + set; + } + + /// + /// 获取或设置触发次数。1 表示一次性,负数表示无限循环,0 表示不创建任务。 + /// + public int RepeatCount + { + get; + set; + } + + /// + /// 获取或设置任务回调。 + /// + public Action Callback + { + get; + set; + } + + /// + /// 获取或设置任务归属对象,用于批量取消。 + /// + public object Owner + { + get; + set; + } + + /// + /// 获取或设置任务时间源。 + /// + public TimerTimeMode TimeMode + { + get; + set; + } + } +} diff --git a/Assets/Plugins/TimerModule/Runtime/TimerTask.cs.meta b/Assets/Plugins/TimerModule/Runtime/TimerTask.cs.meta new file mode 100644 index 0000000..9866e99 --- /dev/null +++ b/Assets/Plugins/TimerModule/Runtime/TimerTask.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: cd3e0d5a67e7bac488d42f26de4f977e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/TimerModule/Runtime/TimerTimeMode.cs b/Assets/Plugins/TimerModule/Runtime/TimerTimeMode.cs new file mode 100644 index 0000000..d6eee8f --- /dev/null +++ b/Assets/Plugins/TimerModule/Runtime/TimerTimeMode.cs @@ -0,0 +1,18 @@ +namespace SepCore.Timer +{ + /// + /// 定时任务使用的时间源。 + /// + public enum TimerTimeMode + { + /// + /// 受 Time.timeScale 影响的游戏时间。 + /// + Scaled = 0, + + /// + /// 不受 Time.timeScale 影响的真实时间。 + /// + Unscaled = 1, + } +} diff --git a/Assets/Plugins/TimerModule/Runtime/TimerTimeMode.cs.meta b/Assets/Plugins/TimerModule/Runtime/TimerTimeMode.cs.meta new file mode 100644 index 0000000..4fa2909 --- /dev/null +++ b/Assets/Plugins/TimerModule/Runtime/TimerTimeMode.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3bd4063a62490e74d938f365a2366e1f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/TimerModule/接入说明.md b/Assets/Plugins/TimerModule/接入说明.md new file mode 100644 index 0000000..2f8e53e --- /dev/null +++ b/Assets/Plugins/TimerModule/接入说明.md @@ -0,0 +1,143 @@ +# TimerModule 接入说明 + +基于 UnityGameFramework 的通用定时器组件插件,支持延迟执行、循环执行、按归属对象批量取消等功能。 + +## 导入步骤 + +1. 将 `Assets/Plugins/TimerModule/` 整个目录导入目标项目 +2. 导入后通过 asmref 自动并入 `SepCore.Runtime` 程序集,基座项目仍可正常编译 +3. 移除 TimerModule 时只需删除整个目录,不会留下残留引用 + +## 程序集结构 + +``` +SepCore.Runtime.Timer.asmref → 引用 SepCore.Runtime,使 TimerComponent 与其他基座 Component 平级 +``` + +- 依赖:`UnityGameFramework.Runtime`(框架自带) +- 无第三方依赖,纯 C# 实现 +- 命名空间:`SepCore.Timer` + +## 场景挂载要求 + +在 GameFramework 框架的组件挂载对象上(通常是场景中的 `GameEntry` 对象): + +1. 添加 `TimerComponent` 组件 +2. 组件会通过 `Update()` 自动驱动,无需额外初始化 +3. AddComponentMenu 路径:`Game Main/Timer` + +## API 使用方式 + +```csharp +using SepCore.Timer; + +// 获取组件引用 +var timer = GameEntry.GetComponent(); + +// 1. 一次性延迟执行 +timer.ScheduleOnce(2f, () => +{ + Debug.Log("2秒后执行"); +}); + +// 2. 无限循环(默认) +timer.ScheduleRepeat(1f, () => +{ + Debug.Log("每秒执行一次"); +}); + +// 3. 指定次数循环(首次延迟 = 循环间隔) +timer.ScheduleRepeat(0.5f, () => +{ + Debug.Log("每0.5秒执行,共执行10次"); +}, repeatCount: 10); + +// 4. 自定义首次延迟 + 循环间隔 +timer.ScheduleRepeat(delay: 3f, interval: 1f, () => +{ + Debug.Log("3秒后开始,然后每秒执行一次"); +}); + +// 5. 使用 unscaledTime(不受 Time.timeScale 影响) +timer.ScheduleRepeat(1f, () => +{ + Debug.Log("暂停时也会执行"); +}, timeMode: TimerTimeMode.Unscaled); + +// 6. 取消单个任务 +TimerHandle handle = timer.ScheduleOnce(5f, Callback); +timer.Cancel(handle); // 提前取消 + +// 7. 按归属对象批量取消(适合 MonoBehaviour 销毁时清理) +private void OnDestroy() +{ + timer.CancelByOwner(this); // 取消所有归属于当前对象的定时器 +} + +// 8. 全局暂停/恢复 +timer.Pause(); // 暂停所有定时器 +timer.Resume(); // 恢复所有定时器 + +// 9. 清理所有 +timer.ClearAll(); +``` + +## TimerHandle 说明 + +- 值类型,可安全作为成员变量存储 +- `handle.IsValid` - 检查句柄是否有效 +- `TimerHandle.Invalid` - 无效句柄常量 +- 实现 `IEquatable`,可用于字典键或比较 + +## 参数说明 + +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `delay` | float | - | 首次触发前的延迟时间(秒) | +| `interval` | float | - | 循环触发间隔(秒),一次性任务忽略 | +| `repeatCount` | int | -1 | 触发次数:1=一次性,负数=无限循环,0=不创建 | +| `owner` | object | null | 任务归属对象,用于 `CancelByOwner` 批量取消 | +| `timeMode` | TimerTimeMode | Scaled | 时间源:Scaled 受 timeScale 影响,Unscaled 不受影响 | + +## 最佳实践 + +1. **MonoBehaviour 中使用时务必设置 owner**,并在 `OnDestroy` 中调用 `CancelByOwner(this)` 避免回调执行在已销毁对象上 + +2. **在回调中取消自身是安全的**:回调执行时标记取消,当前帧更新结束后统一清理 + +3. **精度说明**:定时器基于帧更新,精度受帧率影响,适合游戏逻辑计时,不适合高精度物理模拟 + +4. **推荐使用便捷方法**:`ScheduleOnce` 和 `ScheduleRepeat` 比直接 `Schedule(TimerTask)` 更清晰 + +## 编辑器调试功能 + +TimerModule 内置了完整的 Editor 调试扩展,选中挂载 `TimerComponent` 的对象即可看到: + +### 统计信息面板 +- **活跃任务总数** - 当前正在运行的任务数量 +- **Scaled/Unscaled 分布** - 受时间缩放影响的任务 vs 不受影响的任务 +- **无限循环数** - `repeatCount < 0` 的永久任务数量 +- **异常任务数** - 回调曾抛出异常的任务(红色高亮) +- **全局运行状态** - 绿色=运行中,橙色=已暂停 + +### 任务列表可视化 +- **ID + 时间模式标签** - 快速识别任务,青色=Scaled,黄色=Unscaled +- **进度条** - 显示剩余时间和当前周期完成进度 + - 一次性任务:显示延迟完成度 + - 循环任务:显示当前周期进度 +- **回调方法名** - `Class.Method` 格式,定位来源 +- **归属对象** - 点击可直接 Ping 到场景中的 Owner 对象 +- **异常标记** - 曾抛异常的任务会有红色背景警告 + +### 调试控制 +- **暂停/恢复全部** - 一键控制全局定时器 +- **单任务取消** - 每个任务右侧的取消按钮,可随时终止 +- **清理全部** - 一键清除所有任务(含确认对话框) + +## 性能特性 + +- 回调异常自动捕获并 LogError,不会中断其他任务或游戏 +- 帧更新时遍历取消采用反向遍历,避免索引错乱 +- 更新期间的删除操作延迟到帧末执行,避免并发问题 +- O(n) 线性遍历,适合千级以内任务规模 +- Editor 调试面板每帧自动刷新,不影响 Build 性能 diff --git a/Assets/Plugins/TimerModule/接入说明.md.meta b/Assets/Plugins/TimerModule/接入说明.md.meta new file mode 100644 index 0000000..b3287eb --- /dev/null +++ b/Assets/Plugins/TimerModule/接入说明.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 46ef54076114f954aa6d941be3291bf7 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: