TimeSystem
Диспетчер отложенных вызовов и источник времени приложения.
Назначение
Централизованное управление отложенными вызовами, аккумуляция однотипных действий, управляемые таймеры.
- Отложенный вызов действий через заданный интервал
- Аккумуляция однотипных вызовов (batching)
- Управляемые таймеры с паузой и отменой
- Кеширование времени на кадр (
Date,Time,Timestamp) - Конвертация времени (Unix seconds, ticks →
DateTime)
Вне ответственности: корутины, анимации, интерполяция (см. TweenerSystem).
Зависимости
UnityEngine—MonoBehaviour,DontDestroyOnLoadSirenix.OdinInspector— отладочное отображение очередей
TimeController
Центральный диспетчер отложенных вызовов. MonoBehaviour, создаётся автоматически через [RuntimeInitializeOnLoadMethod].
Архитектура
TimeController (MonoBehaviour, auto-create)
├── _queue — Dictionary<object, QueuedAction> (с владельцем, перезаписывается)
├── _anonymousQueue — List<QueuedAction> (без владельца, FIFO)
├── NextWaveQueue — Dictionary<object, Action> (Accumulate)
├── ReadyQueue — List<QueuedAction> (снимок текущей волны: Owner + Action)
├── HotRemovedOwners — HashSet<object> (отмены, пришедшие во время волны)
├── RemoveBuffer — List<object> (буфер)
└── RemoveIndices — List<int> (буфер)
Цикл обработки:
Update() → TimeSync?.Invoke()
LateUpdate() → SetTimeValue()
→ RunNextWave() // Accumulate-батч
→ CheckQueue() // каждые 0.1с (StepTime)
Контракт
Вход:
Action+ опциональная задержка (float stepSecs) + опциональный владелец (T owner where T : class)
Выход:
- Вызов action по истечении задержки
- Кешированное время:
Date,Time,Timestamp
Гарантии:
- Anonymous (без owner): FIFO порядок, не отменяется
- С owner: перезаписывает предыдущий вызов того же owner
Accumulate: выполняется один раз заLateUpdate, сохраняется последний action- Исключение в одном callback не блокирует остальные (
try/catch+Debug.LogError) _nextTimerоптимизация:CheckQueueпропускается при отсутствии готовых к выполнению действийCall(null, owner)— удаляет pending-вызов owner из очереди- Реентрантная отмена в волне:
RemoveCall(owner), вызванный из action'а, который выполняется прямо сейчас в текущей волне, корректно гасит ещё не выполненные action'ы того же owner из снимкаReadyQueue. Пока волна идёт (_inWave), отменённый owner попадает вHotRemovedOwners, и его action не выстрелит из уже снятого снимка. Остальные action'ы волны выполняются нормально. Без этого механизма отмена «изнутри волны» опаздывала (снимок уже снят) и action срабатывал вопрекиRemoveCall.
Ограничения:
- Гранулярность ~100мс (
StepTime). ПриstepSecs <= 0проверка форсируется на текущемLateUpdate - Owner ограничен
where T : class— value-типы отсекаются на этапе компиляции
Использование
Отложенные вызовы
// Без владельца (FIFO, нельзя отменить)
TimeController.Call(() => Refresh());
// С задержкой, без владельца
TimeController.Call(() => Refresh(), 0.5f);
// С владельцем (перезаписывается, можно отменить)
TimeController.Call(() => Save(), this);
TimeController.Call(() => Save(), 2f, this);
// Отмена по владельцу
TimeController.RemoveCall(this);
⚠️
Callработает по wall-clock (DateTime.UtcNow) и не знает про игровую паузу: запланированный вызов сработает, даже если игра стоит на паузе или приложение в анфокусе (пауза проекта стейтовая,Time.timeScaleне обнуляется). Для отложенных действий, которые должны «замерзать» вместе с игрой, используйтеTimer— см. Паттерн: паузируемое отложенное действие.
Аккумуляция
// Множественные вызовы за кадр — выполнится только последний
TimeController.Accumulate(() => Sync(), this);
TimeController.Accumulate(() => Sync(), this);
// Sync() вызовется один раз в следующем LateUpdate
Время
DateTime now = TimeController.Date; // UtcNow, кеш на кадр
double seconds = TimeController.Time; // секунды, точность 0.01
long unixMs = TimeController.Timestamp; // Unix milliseconds
DateTime local = TimeController.DateFromSeconds(unixSec);
DateTime local = TimeController.DateFromTicks(ticks);
Покадровые колбэки (FixedUpdate)
Помимо отложенных вызовов, TimeController ведёт реестр колбэков FixUpdateIndex, вызываемых каждый кадр на FixedUpdate:
// Регистрация колбэка, вызываемого каждый FixedUpdate
TimeController.AddCallback(Tick);
// Снятие из реестра
TimeController.RemoveCallback(Tick);
Колбэки вызываются в FixedUpdate в порядке регистрации; исключение в одном колбэке изолируется (try/catch + Debug.LogException) и не блокирует остальные.
Граничные случаи
- StepTime (0.1с):
CheckQueueвыполняется раз в ~100мс. ПриstepSecs <= 0форсируется проверка на текущемLateUpdate. - Буферы:
ReadyQueue,RemoveBuffer,RemoveIndices,HotRemovedOwners— статические, переиспользуемые, без GC-давления. RemoveCallиз выполняемого action'а: безопасно. Отмена owner'а во время волны гарантированно гасит его ещё не выполненные action'ы текущего снимка (черезHotRemovedOwners) и удаляет изNextWaveQueue.- Timestamp при Date.Year <= 1: возвращает
0(защита отDateTimeOffsetна неинициализированной дате).
Timer
Управляемый таймер с поддержкой паузы. При создании автоматически регистрируется в TimeController.Call с owner = this.
Архитектура
Timer (class)
├── End — DateTime (момент срабатывания, пересчитывается при Resume)
├── Duration — TimeSpan (полная длительность, неизменна)
├── Remains — TimeSpan (оставшееся, из DateTime.UtcNow; на паузе — зафиксированное)
├── IsComplete — bool (true после срабатывания)
├── IsPaused — bool (true между SetPause и Resume)
└── → TimeController.Call(CallAction, seconds, this)
Контракт
Вход:
- Длительность (
floatсекунд,TimeSpan, илиDateTimeцелевой момент) + callbackAction
Выход:
- Вызов callback по истечении
- Состояние:
Remains,IsComplete,IsPaused,GetTimePassed()
Гарантии:
SetPause/Resume— no-op приIsComplete, повторной паузе или отсутствии паузыRemainsвычисляется изDateTime.UtcNow(реальное время, не кеш кадра)- Callback вызывается через
TimeController— изоляция исключений
Ограничения:
- Метода отмены нет. Для отмены:
SetPause()безResume() - Точность callback определяется
TimeController.StepTime(~100мс)
Использование
// Создание
var timer = new Timer(5f, onComplete);
var timer = new Timer(TimeSpan.FromMinutes(1), onComplete);
var timer = new Timer(targetDateTime, onComplete);
// Состояние
TimeSpan left = timer.Remains;
TimeSpan passed = timer.GetTimePassed();
// Пауза / возобновление
timer.SetPause(); // RemoveCall(this), фиксация Remains, IsPaused = true
timer.Resume(); // End = UtcNow + Remains, повторная регистрация в Call
Жизненный цикл:
new Timer(5f, cb)
→ End = UtcNow + 5s
→ TimeController.Call(CallAction, 5f, this)
→ ... 5 секунд ...
→ CallAction(): IsComplete = true, cb?.Invoke()
SetPause()
→ TimeController.RemoveCall(this)
→ _remains = End - UtcNow (через чтение property до IsPaused = true)
→ IsPaused = true
Resume()
→ End = UtcNow + _remains
→ IsPaused = false
→ TimeController.Call(CallAction, (float)Remains.TotalSeconds, this)
Граничные случаи
- Уход в фон:
DateTime.UtcNowпродолжает тикать,LateUpdateостанавливается.Remainsкорректен после возврата; callback срабатывает на первомCheckQueue. - SetPause — порядок операций: фиксирует
Remainsчерез чтение property до установкиIsPaused = true. ПослеIsPaused = truegetter возвращает кешированное значение.
Паттерн: паузируемое отложенное действие
Timer — не только «таймер с прогрессом», но и pause-safe замена TimeController.Call
для отложенных шагов геймплея и визуала.
Выбор инструмента
| Сценарий | Инструмент |
|---|---|
| Отложить на конец кадра / батчинг | Call / Accumulate |
| Отложенный вызов, которому пауза безразлична (app-уровень, аналитика, звук вне геймплея) | Call(action, delay, owner) |
| Отложенный геймплейный/визуальный шаг, который должен замереть на паузе (резолв удара, смена фазы, возврат анимации, скрытие реплики) | Timer + пауза по стейту |
| Нужен прогресс/остаток времени (слайдеры, обратный отсчёт) | Timer (Remains, GetTimePassed) |
Корень различия: пауза проекта — состояние (MiniGameStates.Paused), а не Time.timeScale = 0.
Очередь TimeController тикает по wall-clock и на паузе продолжает срабатывать.
Timer умеет SetPause/Resume с заморозкой остатка — но только если его явно дёргать
из обработчика смены состояния. Созданный и забытый Timer ведёт себя как обычный Call.
Канонический сниппет
private Timer _actionTimer;
// Запуск отложенного шага (вместо TimeController.Call(cb, delay, this))
_actionTimer?.SetPause(); // отмена предыдущего, если был
_actionTimer = new Timer(delay, Callback); // конструктор сразу запускает отсчёт!
// Обработчик смены состояния игры (OnGameStateChanged / OnStateChanged)
private void OnStateChanged(MiniGameStates state)
{
switch (state)
{
case MiniGameStates.Play:
_actionTimer?.Resume();
break;
case MiniGameStates.Paused:
_actionTimer?.SetPause();
break;
}
}
// Очистка (DeInit / OnDisable / Unbind)
_actionTimer?.SetPause(); // метода отмены нет — пауза без Resume и есть отмена
_actionTimer = null;
Грабли
- Конструктор
Timerзапускает отсчёт немедленно. «Создать на паузе» нельзя — создавайте только из кода, который гарантированно выполняется вPlay(обработчики событий геймплея), либо сразу же ставьте на паузу. - Один таймер — одно действие. Для цепочек шагов достаточно одного поля, если шаги последовательные (новый шаг отменяет предыдущий). Для независимых параллельных дедлайнов — список и проверка по игровому времени (см. ниже).
- Если есть «игровое время» (аудиотрек, тикающий счётчик) — оно лучше таймера.
Дедлайн, привязанный к треку, замерзает вместе с ним бесплатно:
храните цель (
targetTime) и сравнивайте в обработчике тика времени, который уже гардится на состоянии «игра идёт».