Compare commits

...

3 Commits

49 changed files with 1406 additions and 59 deletions

View File

@ -7,6 +7,7 @@ using SepCore.CameraModule;
using SepCore.SpriteCache;
using SepCore.UIRouter;
using SepCore.Simulation;
using SepCore.Timer;
/// <summary>
/// 游戏入口。
@ -31,6 +32,8 @@ public partial class GameEntry
public static CameraModuleComponent CameraModule { get; private set; }
public static TimerComponent Timer { get; private set; }
private static void InitCustomComponents()
{
BuiltinData = UnityGameFramework.Runtime.GameEntry.GetComponent<BuiltinDataComponent>();
@ -42,5 +45,6 @@ public partial class GameEntry
UIRouter = UnityGameFramework.Runtime.GameEntry.GetComponent<UIRouterComponent>();
InputModule = UnityGameFramework.Runtime.GameEntry.GetComponent<InputModuleComponent>();
CameraModule = UnityGameFramework.Runtime.GameEntry.GetComponent<CameraModuleComponent>();
Timer = UnityGameFramework.Runtime.GameEntry.GetComponent<TimerComponent>();
}
}

View File

@ -1,6 +1,7 @@
using SepCore.Components;
using SepCore.CustomUtility;
using SepCore.Definition;
using SepCore.Timer;
using UnityEngine;
using UnityGameFramework.Runtime;
@ -23,7 +24,8 @@ namespace SepCore.Entity
private int _attackDamage = 1;
private float _sqrAttackRange;
private float _currAttackTimer;
private TimerHandle _attackTimerHandle;
private bool _canAttack;
private AttackStateType _attackState = AttackStateType.Idle;
@ -69,7 +71,8 @@ namespace SepCore.Entity
_attackDamage = Mathf.Max(1, _meleeEnemyData.AttackDamage);
_sqrAttackRange = _attackRange * _attackRange;
_currAttackTimer = 0f;
_canAttack = false;
_attackTimerHandle = GameEntry.Timer.ScheduleOnce(_attackCooldown, () => _canAttack = true, this);
_attackState = AttackStateType.Idle;
_targetableTarget = null;
@ -93,7 +96,8 @@ namespace SepCore.Entity
_movementComponent.OnReset();
_healthComponent.OnReset();
_targetableTarget = null;
_currAttackTimer = 0f;
GameEntry.Timer.Cancel(_attackTimerHandle);
_attackTimerHandle = TimerHandle.Invalid;
_attackState = AttackStateType.Idle;
base.OnHide(isShutdown, userData);
@ -109,8 +113,6 @@ namespace SepCore.Entity
private void UpdateAttackState(float elapseSeconds)
{
_currAttackTimer += elapseSeconds;
switch (_attackState)
{
case AttackStateType.Idle:
@ -151,8 +153,10 @@ namespace SepCore.Entity
return;
}
if (_currAttackTimer >= _attackCooldown)
if (_canAttack)
{
_canAttack = false;
_attackTimerHandle = GameEntry.Timer.ScheduleOnce(_attackCooldown, () => _canAttack = true, this);
TransitionTo(AttackStateType.Attack);
}
@ -169,21 +173,15 @@ namespace SepCore.Entity
SetMove(false);
}
if (_attackState == AttackStateType.Check_InRange && _currAttackTimer >= _attackCooldown)
{
TransitionTo(AttackStateType.Attack);
return;
}
if (_attackState == AttackStateType.Attack)
{
SetMove(false);
ExecuteAttack();
_currAttackTimer = 0f;
TransitionTo(AttackStateType.Check_InRange);
}
}
private bool HasValidTarget()
{
if (_target == null) return false;

View File

@ -3,6 +3,7 @@ using SepCore.AsyncTask;
using SepCore.CustomUtility;
using SepCore.Definition;
using Cysharp.Threading.Tasks;
using SepCore.Timer;
using UnityEngine;
using UnityGameFramework.Runtime;
@ -27,7 +28,8 @@ namespace SepCore.Entity
private float _attackRangeSquared;
private float _attackCooldown = 1f;
private int _attackDamage = 1;
private float _currAttackTimer;
private TimerHandle _attackTimerHandle;
private bool _canAttack;
[SerializeField] private float _projectileSpeed = 12f;
[SerializeField] private float _projectileLifeTime = 3f;
@ -81,7 +83,8 @@ namespace SepCore.Entity
_projectileSpawnHeightOffset);
_projectileAssetName = ReadStringParam(ProjectileAssetNameParamKey, _projectileAssetName);
_currAttackTimer = 0f;
_canAttack = false;
_attackTimerHandle = GameEntry.Timer.ScheduleOnce(_attackCooldown, () => _canAttack = true, this);
this.CachedTransform.position = enemyData.Position;
}
else
@ -94,8 +97,6 @@ namespace SepCore.Entity
{
base.OnUpdate(elapseSeconds, realElapseSeconds);
_currAttackTimer += elapseSeconds;
if (_target == null)
{
_movementComponent.SetMove(false);
@ -108,7 +109,12 @@ namespace SepCore.Entity
if (distanceSquared <= _attackRangeSquared)
{
_movementComponent.SetMove(false);
TryFireProjectile();
if (_canAttack)
{
TryFireProjectile();
_canAttack = false;
_attackTimerHandle = GameEntry.Timer.ScheduleOnce(_attackCooldown, () => _canAttack = true, this);
}
}
else
{
@ -121,14 +127,14 @@ namespace SepCore.Entity
{
_movementComponent.OnReset();
_healthComponent.OnReset();
_currAttackTimer = 0f;
GameEntry.Timer.Cancel(_attackTimerHandle);
_attackTimerHandle = TimerHandle.Invalid;
base.OnHide(isShutdown, userData);
}
private void TryFireProjectile()
{
if (_currAttackTimer < _attackCooldown || _target == null) return;
if (!EnsureEnemyProjectileGroup()) return;
Vector3 spawnPosition = this.CachedTransform.position +
@ -165,8 +171,6 @@ namespace SepCore.Entity
EnemyProjectileGroupName,
Constant.AssetPriority.BulletAsset,
projectileData).Forget();
_currAttackTimer = 0f;
}
private static bool EnsureEnemyProjectileGroup()

View File

@ -4,6 +4,7 @@ using SepCore.Definition;
using SepCore.Entity;
using GameFramework;
using SepCore.CustomUtility;
using SepCore.Timer;
using UnityEngine;
using UnityGameFramework.Runtime;
@ -41,7 +42,8 @@ namespace SepCore.Entity.Weapon
protected EntityBase _target;
protected float _currAttackTimer;
protected bool _canAttack;
protected TimerHandle _attackTimerHandle;
protected float _sqrRange;
@ -306,6 +308,26 @@ namespace SepCore.Entity.Weapon
_attackStatCallback = null;
AttackStat = new StatProperty();
}
protected void StartAttackCooldown(float cooldown)
{
_canAttack = false;
_attackTimerHandle = GameEntry.Timer.ScheduleOnce(cooldown, () => _canAttack = true, this);
}
protected void ResetAttackCooldown(float cooldown)
{
GameEntry.Timer.Cancel(_attackTimerHandle);
_canAttack = false;
_attackTimerHandle = GameEntry.Timer.ScheduleOnce(cooldown, () => _canAttack = true, this);
}
protected void CancelAttackCooldown()
{
GameEntry.Timer.Cancel(_attackTimerHandle);
_attackTimerHandle = TimerHandle.Invalid;
_canAttack = false;
}
}
public abstract class WeaponStateBase

View File

@ -10,7 +10,7 @@ namespace SepCore.Entity.Weapon
public override void OnEnter()
{
_weapon._currAttackTimer = 0f;
_weapon.ResetAttackCooldown(_weapon._weaponData.Cooldown);
_weapon.Attack();
}

View File

@ -10,7 +10,7 @@ namespace SepCore.Entity.Weapon
public override void OnEnter()
{
if (_weapon._currAttackTimer >= _weapon._weaponData.Cooldown)
if (_weapon._canAttack)
{
_weapon.TransitionTo(WeaponStateType.Attack);
}
@ -20,7 +20,6 @@ namespace SepCore.Entity.Weapon
{
_weapon.Check();
_weapon.RotateToTarget(elapseSeconds);
_weapon._currAttackTimer += elapseSeconds;
if (_weapon._target == null || !_weapon._target.Available)
{
@ -34,7 +33,7 @@ namespace SepCore.Entity.Weapon
return;
}
if (_weapon._currAttackTimer >= _weapon._weaponData.Cooldown)
if (_weapon._canAttack)
{
_weapon.TransitionTo(WeaponStateType.Attack);
}

View File

@ -13,7 +13,6 @@ namespace SepCore.Entity.Weapon
{
_weapon.Check();
_weapon.RotateToTarget(elapseSeconds);
_weapon._currAttackTimer += elapseSeconds;
if (_weapon._target == null || !_weapon._target.Available)
{

View File

@ -13,7 +13,6 @@ namespace SepCore.Entity.Weapon
{
_weapon.Check();
_weapon.RotateToOrigin(elapseSeconds);
_weapon._currAttackTimer += elapseSeconds;
if (_weapon._target != null && _weapon._target.Available)
{

View File

@ -129,7 +129,7 @@ namespace SepCore.Entity.Weapon
if (_weaponData == null) return false;
WeaponData = _weaponData;
_currAttackTimer = 0f;
StartAttackCooldown(_weaponData.Cooldown);
_sqrRange = _weaponData.AttackRange * _weaponData.AttackRange;
_cachedRotation = CachedTransform.rotation;
_attackEffect = new HandgunHitMarkerAttackEffect(_hitMarkerSize, _hitMarkerYOffset, _hitMarkerDuration,
@ -151,6 +151,7 @@ namespace SepCore.Entity.Weapon
protected override void OnWeaponHide(object userData)
{
CancelAttackCooldown();
_attackEffect = null;
}

View File

@ -11,7 +11,7 @@ namespace SepCore.Entity.Weapon
public override void OnEnter()
{
_weapon._currAttackTimer = 0f;
_weapon.ResetAttackCooldown(_weapon._weaponData.Cooldown);
_weapon.Attack();
}

View File

@ -11,7 +11,7 @@ namespace SepCore.Entity.Weapon
public override void OnEnter()
{
if (_weapon._currAttackTimer >= _weapon._weaponData.Cooldown)
if (_weapon._canAttack)
{
_weapon.TransitionTo(WeaponStateType.Attack);
}
@ -21,7 +21,6 @@ namespace SepCore.Entity.Weapon
{
_weapon.Check();
_weapon.RotateToTarget(elapseSeconds);
_weapon._currAttackTimer += elapseSeconds;
if (_weapon._target == null || !_weapon._target.Available)
{
@ -35,7 +34,7 @@ namespace SepCore.Entity.Weapon
return;
}
if (_weapon._currAttackTimer >= _weapon._weaponData.Cooldown)
if (_weapon._canAttack)
{
_weapon.TransitionTo(WeaponStateType.Attack);
}

View File

@ -17,7 +17,6 @@ namespace SepCore.Entity.Weapon
{
_weapon.Check();
_weapon.RotateToTarget(elapseSeconds);
_weapon._currAttackTimer += elapseSeconds;
if (_weapon._target == null || !_weapon._target.Available)
{

View File

@ -17,7 +17,6 @@ namespace SepCore.Entity.Weapon
{
_weapon.Check();
_weapon.RotateToOrigin(elapseSeconds);
_weapon._currAttackTimer += elapseSeconds;
if (_weapon._target != null && _weapon._target.Available)
{

View File

@ -150,7 +150,7 @@ namespace SepCore.Entity.Weapon
if (_weaponData == null) return false;
WeaponData = _weaponData;
_currAttackTimer = 0f;
StartAttackCooldown(_weaponData.Cooldown);
_sqrRange = _weaponData.AttackRange * _weaponData.AttackRange;
_cachedRotation = CachedTransform.rotation;
@ -183,6 +183,7 @@ namespace SepCore.Entity.Weapon
protected override void OnWeaponHide(object userData)
{
StopAttackTween(true);
CancelAttackCooldown();
_attackEffect = null;
}

View File

@ -11,7 +11,7 @@ namespace SepCore.Entity.Weapon
public override void OnEnter()
{
_weapon._currAttackTimer = 0f;
_weapon.ResetAttackCooldown(_weapon._weaponData.Cooldown);
_weapon.Attack();
}

View File

@ -11,7 +11,7 @@ namespace SepCore.Entity.Weapon
public override void OnEnter()
{
if (_weapon._currAttackTimer >= _weapon._weaponData.Cooldown)
if (_weapon._canAttack)
{
_weapon.TransitionTo(WeaponStateType.Attack);
}
@ -21,7 +21,6 @@ namespace SepCore.Entity.Weapon
{
_weapon.Check();
_weapon.RotateToTarget(elapseSeconds);
_weapon._currAttackTimer += elapseSeconds;
if (_weapon._target == null || !_weapon._target.Available)
{
@ -35,7 +34,7 @@ namespace SepCore.Entity.Weapon
return;
}
if (_weapon._currAttackTimer >= _weapon._weaponData.Cooldown)
if (_weapon._canAttack)
{
_weapon.TransitionTo(WeaponStateType.Attack);
}

View File

@ -17,7 +17,6 @@ namespace SepCore.Entity.Weapon
{
_weapon.Check();
_weapon.RotateToTarget(elapseSeconds);
_weapon._currAttackTimer += elapseSeconds;
if (_weapon._target == null || !_weapon._target.Available)
{

View File

@ -17,7 +17,6 @@ namespace SepCore.Entity.Weapon
{
_weapon.Check();
_weapon.RotateToOrigin(elapseSeconds);
_weapon._currAttackTimer += elapseSeconds;
if (_weapon._target != null && _weapon._target.Available)
{

View File

@ -244,7 +244,7 @@ namespace SepCore.Entity.Weapon
if (_weaponData == null) return false;
WeaponData = _weaponData;
_currAttackTimer = 0f;
StartAttackCooldown(_weaponData.Cooldown);
_sqrRange = _weaponData.AttackRange * _weaponData.AttackRange;
_cachedRotation = CachedTransform.rotation;
@ -308,6 +308,7 @@ namespace SepCore.Entity.Weapon
protected override void OnWeaponHide(object userData)
{
StopAttackTween(true);
CancelAttackCooldown();
_attackEffect = null;
}

View File

@ -11,7 +11,7 @@ namespace SepCore.Entity.Weapon
public override void OnEnter()
{
_weapon._currAttackTimer = 0f;
_weapon.ResetAttackCooldown(_weapon._weaponData.Cooldown);
_weapon.Attack();
}

View File

@ -11,7 +11,7 @@ namespace SepCore.Entity.Weapon
public override void OnEnter()
{
if (_weapon._currAttackTimer >= _weapon._weaponData.Cooldown)
if (_weapon._canAttack)
{
_weapon.TransitionTo(WeaponStateType.Attack);
}
@ -21,7 +21,6 @@ namespace SepCore.Entity.Weapon
{
_weapon.Check();
_weapon.RotateToTarget(elapseSeconds);
_weapon._currAttackTimer += elapseSeconds;
if (_weapon._target == null || !_weapon._target.Available)
{
@ -35,7 +34,7 @@ namespace SepCore.Entity.Weapon
return;
}
if (_weapon._currAttackTimer >= _weapon._weaponData.Cooldown)
if (_weapon._canAttack)
{
_weapon.TransitionTo(WeaponStateType.Attack);
}

View File

@ -17,7 +17,6 @@ namespace SepCore.Entity.Weapon
{
_weapon.Check();
_weapon.RotateToTarget(elapseSeconds);
_weapon._currAttackTimer += elapseSeconds;
if (_weapon._target == null || !_weapon._target.Available)
{

View File

@ -17,7 +17,6 @@ namespace SepCore.Entity.Weapon
{
_weapon.Check();
_weapon.RotateToOrigin(elapseSeconds);
_weapon._currAttackTimer += elapseSeconds;
if (_weapon._target != null && _weapon._target.Available)
{

View File

@ -171,7 +171,7 @@ namespace SepCore.Entity.Weapon
if (_weaponData == null) return false;
WeaponData = _weaponData;
_currAttackTimer = 0f;
StartAttackCooldown(_weaponData.Cooldown);
_sqrRange = _weaponData.AttackRange * _weaponData.AttackRange;
_cachedRotation = CachedTransform.rotation;
@ -209,6 +209,7 @@ namespace SepCore.Entity.Weapon
protected override void OnWeaponHide(object userData)
{
StopAttackTween(true);
CancelAttackCooldown();
_attackEffect = null;
}

View File

@ -10,7 +10,7 @@ namespace SepCore.Entity.Weapon
public override void OnEnter()
{
_weapon._currAttackTimer = 0f;
_weapon.ResetAttackCooldown(_weapon._weaponData.Cooldown);
_weapon.Attack();
}

View File

@ -10,7 +10,7 @@ namespace SepCore.Entity.Weapon
public override void OnEnter()
{
if (_weapon._currAttackTimer >= _weapon._weaponData.Cooldown)
if (_weapon._canAttack)
{
_weapon.TransitionTo(WeaponStateType.Attack);
}
@ -20,7 +20,6 @@ namespace SepCore.Entity.Weapon
{
_weapon.Check();
_weapon.RotateToTarget(elapseSeconds);
_weapon._currAttackTimer += elapseSeconds;
if (_weapon._target == null || !_weapon._target.Available)
{
@ -34,7 +33,7 @@ namespace SepCore.Entity.Weapon
return;
}
if (_weapon._currAttackTimer >= _weapon._weaponData.Cooldown)
if (_weapon._canAttack)
{
_weapon.TransitionTo(WeaponStateType.Attack);
}

View File

@ -16,7 +16,6 @@ namespace SepCore.Entity.Weapon
{
_weapon.Check();
_weapon.RotateToTarget(elapseSeconds);
_weapon._currAttackTimer += elapseSeconds;
if (_weapon._target == null || !_weapon._target.Available)
{

View File

@ -16,7 +16,6 @@ namespace SepCore.Entity.Weapon
{
_weapon.Check();
_weapon.RotateToOrigin(elapseSeconds);
_weapon._currAttackTimer += elapseSeconds;
if (_weapon._target != null && _weapon._target.Available)
{

View File

@ -174,7 +174,7 @@ namespace SepCore.Entity.Weapon
if (_weaponData == null) return false;
WeaponData = _weaponData;
_currAttackTimer = 0f;
StartAttackCooldown(_weaponData.Cooldown);
_sqrRange = _weaponData.AttackRange * _weaponData.AttackRange;
_cachedRotation = CachedTransform.rotation;
@ -209,6 +209,7 @@ namespace SepCore.Entity.Weapon
protected override void OnWeaponHide(object userData)
{
StopAttackTween(true);
CancelAttackCooldown();
_attackEffect = null;
}

View File

@ -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

View File

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

View File

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

View File

@ -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
}

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 2703d0b6ecf886044905648043ceb65d
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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 ? "<color=orange>已暂停</color>" : "<color=green>运行中</color>", 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($"<color=#{ColorUtility.ToHtmlStringRGB(modeColor)}>{modeLabel}</color>", 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;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e536163046bf5ff40bd04430d3a45af5
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

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

View File

@ -0,0 +1,3 @@
{
"reference": "GUID:436e23dbdc31e7d4fb5c3f804548b2df"
}

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 4fad44cf84287fc45a187478fb6c9891
AssemblyDefinitionReferenceImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,622 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityGameFramework.Runtime;
namespace SepCore.Timer
{
/// <summary>
/// 通用定时器组件。
/// </summary>
[DisallowMultipleComponent]
[AddComponentMenu("Game Main/Timer")]
public sealed class TimerComponent : GameFrameworkComponent
{
private readonly List<TimerTaskInfo> _timerTasks = new List<TimerTaskInfo>();
private bool _isPaused = false;
private bool _isUpdating = false;
private int _serialId = 0;
/// <summary>
/// 获取当前定时任务数量。
/// </summary>
public int Count => _timerTasks.Count;
/// <summary>
/// 获取定时器是否暂停。
/// </summary>
public bool IsPaused => _isPaused;
private void Update()
{
if (_isPaused)
{
return;
}
UpdateTasks(Time.deltaTime, Time.unscaledDeltaTime);
}
private void OnDestroy()
{
ClearAll();
}
/// <summary>
/// 创建定时任务。
/// </summary>
/// <param name="timerTask">定时任务描述。</param>
/// <returns>定时任务句柄。</returns>
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);
}
/// <summary>
/// 创建一次性定时任务。
/// </summary>
public TimerHandle ScheduleOnce(float delay, Action callback, object owner = null,
TimerTimeMode timeMode = TimerTimeMode.Scaled)
{
return Schedule(new TimerTask(delay, delay, 1, callback, owner, timeMode));
}
/// <summary>
/// 创建循环定时任务,首次触发延迟等于循环间隔。
/// </summary>
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);
}
/// <summary>
/// 创建循环定时任务。
/// </summary>
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));
}
/// <summary>
/// 取消定时任务。
/// </summary>
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;
}
/// <summary>
/// 取消归属于指定对象的全部定时任务。
/// </summary>
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;
}
/// <summary>
/// 暂停所有定时任务。
/// </summary>
public void Pause()
{
_isPaused = true;
}
/// <summary>
/// 恢复所有定时任务。
/// </summary>
public void Resume()
{
_isPaused = false;
}
/// <summary>
/// 清理所有定时任务。
/// </summary>
public void ClearAll()
{
if (!_isUpdating)
{
_timerTasks.Clear();
return;
}
for (int i = 0; i < _timerTasks.Count; i++)
{
_timerTasks[i].IsCancelled = true;
}
}
/// <summary>
/// 获取所有活跃任务的快照(调试用)。
/// </summary>
public TimerTaskSnapshot[] GetTaskSnapshots()
{
List<TimerTaskSnapshot> snapshots = new List<TimerTaskSnapshot>(_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();
}
/// <summary>
/// 设置任务的循环间隔。
/// </summary>
/// <param name="handle">任务句柄。</param>
/// <param name="newInterval">新的间隔时间(秒)。</param>
/// <param name="adjustRemainingTime">是否根据新旧间隔的比例调整当前剩余时间。</param>
/// <returns>是否设置成功。</returns>
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;
}
/// <summary>
/// 暂停单个任务。
/// </summary>
/// <param name="handle">任务句柄。</param>
/// <returns>是否暂停成功。</returns>
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;
}
/// <summary>
/// 恢复单个任务。
/// </summary>
/// <param name="handle">任务句柄。</param>
/// <returns>是否恢复成功。</returns>
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;
}
/// <summary>
/// 获取任务的剩余时间。
/// </summary>
/// <param name="handle">任务句柄。</param>
/// <returns>剩余时间(秒),任务不存在返回 -1f。</returns>
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;
}
/// <summary>
/// 获取任务的循环间隔。
/// </summary>
/// <param name="handle">任务句柄。</param>
/// <returns>循环间隔(秒),任务不存在返回 -1f。</returns>
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;
}
/// <summary>
/// 获取任务的剩余重复次数。
/// </summary>
/// <param name="handle">任务句柄。</param>
/// <returns>剩余重复次数,任务不存在返回 -1。</returns>
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;
}
/// <summary>
/// 重置任务的剩余时间。
/// </summary>
/// <param name="handle">任务句柄。</param>
/// <param name="newRemainingTime">新的剩余时间(秒)。</param>
/// <returns>是否重置成功。</returns>
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;
}
/// <summary>
/// 任务是否已暂停。
/// </summary>
/// <param name="handle">任务句柄。</param>
/// <returns>任务是否已暂停,任务不存在返回 false。</returns>
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;
}
}
}
/// <summary>
/// 定时任务快照(调试用)。
/// </summary>
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;
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 86583a9107c4c3b45a888f4cc47c2856
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,55 @@
using System;
namespace SepCore.Timer
{
/// <summary>
/// 定时任务句柄。
/// </summary>
public readonly struct TimerHandle : IEquatable<TimerHandle>
{
public static readonly TimerHandle Invalid = new TimerHandle(0);
public TimerHandle(int id)
{
Id = id;
}
/// <summary>
/// 获取定时任务编号。
/// </summary>
public int Id
{
get;
}
/// <summary>
/// 获取句柄是否有效。
/// </summary>
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);
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d24d22014412ca54eaee690f92779349
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,75 @@
using System;
namespace SepCore.Timer
{
/// <summary>
/// 定时任务描述。
/// </summary>
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;
}
/// <summary>
/// 获取或设置首次触发前的延迟时间(秒)。
/// </summary>
public float Delay
{
get;
set;
}
/// <summary>
/// 获取或设置循环触发间隔(秒)。一次性任务会忽略该值。
/// </summary>
public float Interval
{
get;
set;
}
/// <summary>
/// 获取或设置触发次数。1 表示一次性负数表示无限循环0 表示不创建任务。
/// </summary>
public int RepeatCount
{
get;
set;
}
/// <summary>
/// 获取或设置任务回调。
/// </summary>
public Action Callback
{
get;
set;
}
/// <summary>
/// 获取或设置任务归属对象,用于批量取消。
/// </summary>
public object Owner
{
get;
set;
}
/// <summary>
/// 获取或设置任务时间源。
/// </summary>
public TimerTimeMode TimeMode
{
get;
set;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: cd3e0d5a67e7bac488d42f26de4f977e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,18 @@
namespace SepCore.Timer
{
/// <summary>
/// 定时任务使用的时间源。
/// </summary>
public enum TimerTimeMode
{
/// <summary>
/// 受 Time.timeScale 影响的游戏时间。
/// </summary>
Scaled = 0,
/// <summary>
/// 不受 Time.timeScale 影响的真实时间。
/// </summary>
Unscaled = 1,
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3bd4063a62490e74d938f365a2366e1f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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<TimerComponent>();
// 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<TimerHandle>`,可用于字典键或比较
## 参数说明
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `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 性能

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 46ef54076114f954aa6d941be3291bf7
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant: