// /server/game/instance/TurnTimer.js class TurnTimer { /** * Конструктор таймера хода. * @param {number} turnDurationMs - Изначальная длительность хода в миллисекундах. * @param {number} updateIntervalMs - Интервал для отправки обновлений времени клиентам (в мс). * @param {function} onTimeoutCallback - Колбэк, вызываемый при истечении времени хода. * @param {function} onTickCallback - Колбэк, вызываемый на каждом тике обновления (передает remainingTime, isPlayerTurnForTimer, isPaused). * @param {string} [gameIdForLogs=''] - (Опционально) ID игры для более понятных логов таймера. */ constructor(turnDurationMs, updateIntervalMs, onTimeoutCallback, onTickCallback, gameIdForLogs = '') { this.initialTurnDurationMs = turnDurationMs; // Сохраняем начальную полную длительность хода this.currentEffectiveDurationMs = turnDurationMs; // Длительность, с которой стартует текущий отсчет (может быть меньше initial при resume) this.updateIntervalMs = updateIntervalMs; this.onTimeoutCallback = onTimeoutCallback; this.onTickCallback = onTickCallback; this.gameId = gameIdForLogs; // Для логов this.timeoutId = null; // ID для setTimeout (обработка общего таймаута хода) this.tickIntervalId = null; // ID для setInterval (периодическое обновление клиента) this.startTimeMs = 0; // Время (Date.now()) начала текущего отсчета таймера this.isRunning = false; // Активен ли таймер в данный момент (идет отсчет) // Состояние, для которого был запущен/приостановлен таймер this.isForPlayerTurn = false; // true, если таймер отсчитывает ход игрока (слот 'player') this.isAiCurrentlyMoving = false; // true, если это ход AI, и таймер для реального игрока не должен "тикать" this.isManuallyPaused = false; // Флаг, что таймер был приостановлен вызовом pause() // console.log(`[TurnTimer ${this.gameId}] Initialized. Duration: ${this.initialTurnDurationMs}ms, Interval: ${this.updateIntervalMs}ms`); } /** * Запускает или перезапускает таймер хода. * @param {boolean} isPlayerSlotTurn - true, если сейчас ход слота 'player', false - если ход слота 'opponent'. * @param {boolean} isAiMakingMove - true, если текущий ход делает AI (таймер для реального игрока не тикает). * @param {number|null} [customRemainingTimeMs=null] - Если передано, таймер начнется с этого оставшегося времени. */ start(isPlayerSlotTurn, isAiMakingMove = false, customRemainingTimeMs = null) { this.clear(true); // Очищаем предыдущие таймеры, сохраняя флаг isManuallyPaused если это resume this.isForPlayerTurn = isPlayerSlotTurn; this.isAiCurrentlyMakingMove = isAiMakingMove; // При явном старте (не resume) сбрасываем флаг ручной паузы if (customRemainingTimeMs === null) { this.isManuallyPaused = false; } if (this.isAiCurrentlyMakingMove) { this.isRunning = false; // Для хода AI основной таймер не "бежит" для игрока // console.log(`[TurnTimer ${this.gameId}] Start: AI's turn. Player timer not ticking.`); if (this.onTickCallback) { // Уведомляем один раз, что таймер неактивен (ход AI), передаем isPaused = false (т.к. это не ручная пауза) // Время может быть полным или оставшимся, если AI "думает" this.onTickCallback(this.initialTurnDurationMs, this.isForPlayerTurn, false); } return; } // Устанавливаем длительность для текущего запуска this.currentEffectiveDurationMs = (typeof customRemainingTimeMs === 'number' && customRemainingTimeMs > 0) ? customRemainingTimeMs : this.initialTurnDurationMs; this.startTimeMs = Date.now(); this.isRunning = true; // console.log(`[TurnTimer ${this.gameId}] Started. Effective Duration: ${this.currentEffectiveDurationMs}ms. For ${this.isForPlayerTurn ? 'PlayerSlot' : 'OpponentSlot'}. AI moving: ${this.isAiCurrentlyMakingMove}`); // Основной таймер на истечение времени хода this.timeoutId = setTimeout(() => { // console.log(`[TurnTimer ${this.gameId}] Timeout occurred! Was running: ${this.isRunning}`); if (this.isRunning) { // Доп. проверка, что таймер все еще должен был работать this.isRunning = false; if (this.onTimeoutCallback) { this.onTimeoutCallback(); } this.clear(); // Очищаем и интервал обновления после таймаута } }, this.currentEffectiveDurationMs); // Интервал для отправки обновлений клиентам this.tickIntervalId = setInterval(() => { if (!this.isRunning) { // Если таймер был остановлен (например, ход сделан, игра окончена, или pause вызван), // но интервал еще не очищен - очищаем. this.clear(this.isManuallyPaused); // Сохраняем флаг, если это была ручная пауза return; } const elapsedTime = Date.now() - this.startTimeMs; const remainingTime = Math.max(0, this.currentEffectiveDurationMs - elapsedTime); if (this.onTickCallback) { // isManuallyPaused здесь всегда false, т.к. если бы была пауза, isRunning был бы false this.onTickCallback(remainingTime, this.isForPlayerTurn, false); } if (remainingTime <= 0 && this.isRunning) { // Время вышло по интервалу (на всякий случай, setTimeout должен сработать) // Не вызываем onTimeoutCallback здесь напрямую, чтобы избежать двойного вызова. this.clear(this.isManuallyPaused); // Очищаем интервал, setTimeout сработает для onTimeoutCallback } }, this.updateIntervalMs); // Отправляем начальное значение немедленно if (this.onTickCallback) { this.onTickCallback(this.currentEffectiveDurationMs, this.isForPlayerTurn, false); } } /** * Приостанавливает таймер и возвращает его текущее состояние. * @returns {{remainingTime: number, forPlayerRoleIsPlayer: boolean, isAiCurrentlyMoving: boolean}} * - remainingTime: Оставшееся время в мс. * - forPlayerRoleIsPlayer: true, если таймер был для хода игрока (слот 'player'). * - isAiCurrentlyMoving: true, если это был ход AI. */ pause() { // console.log(`[TurnTimer ${this.gameId}] Pause called. isRunning: ${this.isRunning}, isAiCurrentlyMoving: ${this.isAiCurrentlyMoving}`); let remainingTime = 0; const wasForPlayerTurn = this.isForPlayerTurn; const wasAiMoving = this.isAiCurrentlyMoving; if (this.isAiCurrentlyMakingMove) { // Если это был ход AI, таймер для игрока не тикал, считаем, что у него полное время. // Однако, если AI "думал" и мы хотим сохранить это, логика должна быть сложнее. // Для простоты, если AI ход, то время "не шло" для игрока. remainingTime = this.initialTurnDurationMs; // console.log(`[TurnTimer ${this.gameId}] Paused during AI move. Effective remaining time for player turn: ${remainingTime}ms.`); } else if (this.isRunning) { const elapsedTime = Date.now() - this.startTimeMs; remainingTime = Math.max(0, this.currentEffectiveDurationMs - elapsedTime); // console.log(`[TurnTimer ${this.gameId}] Paused while running. Elapsed: ${elapsedTime}ms, Remaining: ${remainingTime}ms.`); } else { // Если таймер не был запущен (например, уже истек или был очищен), // или был уже на паузе, возвращаем 0 или последнее известное значение. // Если isManuallyPaused уже true, то просто возвращаем то, что было. remainingTime = this.isManuallyPaused ? this.currentEffectiveDurationMs : 0; // currentEffectiveDurationMs тут может быть уже оставшимся временем // console.log(`[TurnTimer ${this.gameId}] Pause called, but timer not actively running or already paused. Returning current/zero remaining time: ${remainingTime}ms.`); } this.isManuallyPaused = true; // Устанавливаем флаг ручной паузы this.clear(true); // Очищаем внутренние таймеры, сохраняя флаг isManuallyPaused this.isRunning = false; // Явно указываем, что отсчет остановлен // Уведомляем клиента, что таймер на паузе if (this.onTickCallback) { // console.log(`[TurnTimer ${this.gameId}] Notifying client of pause. Remaining: ${remainingTime}, ForPlayer: ${wasForPlayerTurn}`); this.onTickCallback(remainingTime, wasForPlayerTurn, true); // isPaused = true } return { remainingTime, forPlayerRoleIsPlayer: wasForPlayerTurn, isAiCurrentlyMoving: wasAiMoving }; } /** * Возобновляет таймер с указанного оставшегося времени и для указанного состояния. * @param {number} remainingTimeMs - Оставшееся время в миллисекундах для возобновления. * @param {boolean} forPlayerSlotTurn - Для чьего хода (слот 'player' = true) возобновляется таймер. * @param {boolean} isAiMakingMove - Был ли это ход AI, когда таймер приостановили (и возобновляем ли ход AI). */ resume(remainingTimeMs, forPlayerSlotTurn, isAiMakingMove) { if (!this.isManuallyPaused) { // console.warn(`[TurnTimer ${this.gameId}] Resume called, but timer was not manually paused. Starting normally or doing nothing.`); // Если не был на ручной паузе, то либо запускаем заново (если не был ход AI), либо ничего не делаем // if (!isAiMakingMove) this.start(forPlayerSlotTurn, false, remainingTimeMs > 0 ? remainingTimeMs : null); // Безопаснее просто выйти, если не был на ручной паузе, GameInstance должен управлять этим. return; } if (remainingTimeMs <= 0) { // console.log(`[TurnTimer ${this.gameId}] Resume called with 0 or less time. Triggering timeout.`); this.isManuallyPaused = false; // Сбрасываем флаг if (this.onTimeoutCallback) { this.onTimeoutCallback(); // Немедленный таймаут } return; } // console.log(`[TurnTimer ${this.gameId}] Resuming. Remaining: ${remainingTimeMs}ms. For ${forPlayerSlotTurn ? 'PlayerSlot' : 'OpponentSlot'}. AI moving: ${isAiMakingMove}`); this.isManuallyPaused = false; // Сбрасываем флаг ручной паузы перед стартом // Запускаем таймер с сохраненным состоянием и оставшимся временем this.start(forPlayerSlotTurn, isAiMakingMove, remainingTimeMs); } /** * Очищает (останавливает) все активные таймеры (setTimeout и setInterval). * @param {boolean} [preserveManuallyPausedFlag=false] - Если true, не сбрасывает флаг isManuallyPaused. * Используется внутренне при вызове clear из pause(). */ clear(preserveManuallyPausedFlag = false) { if (this.timeoutId) { clearTimeout(this.timeoutId); this.timeoutId = null; } if (this.tickIntervalId) { clearInterval(this.tickIntervalId); this.tickIntervalId = null; } const wasPreviouslyRunning = this.isRunning; // Запоминаем, работал ли он до clear this.isRunning = false; // this.startTimeMs = 0; // Не сбрасываем startTime, чтобы pause мог корректно вычислить remainingTime if (!preserveManuallyPausedFlag) { this.isManuallyPaused = false; } // Если таймер был очищен не через pause(), он был активен (и это не был ход AI, который и так не тикает) // то опционально можно уведомить клиента, что таймер больше не тикает (например, ход сделан) // Это может быть полезно, чтобы клиент сбросил свой отображаемый таймер на '--' // if (wasPreviouslyRunning && !this.isAiCurrentlyMakingMove && !this.isManuallyPaused && this.onTickCallback) { // // console.log(`[TurnTimer ${this.gameId}] Cleared while running (not AI, not manual pause). Notifying client.`); // this.onTickCallback(null, this.isForPlayerTurn, this.isManuallyPaused); // remainingTime = null // } // console.log(`[TurnTimer ${this.gameId}] Cleared. Was running: ${wasPreviouslyRunning}. PreservePaused: ${preserveManuallyPausedFlag}`); } /** * Проверяет, активен ли таймер в данный момент (идет ли отсчет). * @returns {boolean} */ isActive() { return this.isRunning; } /** * Проверяет, был ли таймер приостановлен вручную вызовом pause(). * @returns {boolean} */ isPaused() { return this.isManuallyPaused; } } module.exports = TurnTimer;