Управляем кучей таймеров в JavaScript

    В прошлом посте было о том, как я писал игру для конкурса js13kGames, цель которого — уместить свою поделку на стеке открытых web-технологий в 13 килобайт.


    Помимо ухищрений с минификацией, игра вдохновила меня на создание инструмента для управления большим количеством таймеров путём оборачивания их в удобный интерфейс и объединения в группы. Код и кейсы, в которых это может пригодиться — под катом.



    Демо, где можно позапускать ракеты и заценить пару примеров кода


    Где это можно использовать?


    Зачастую, логика в играх разбивается на состояния (states) — небольшие независимые части, например: меню, уровень, экран победы. Если предположить, что игрок может в процессе игры перейти в меню, поставив происходящее в уровне на паузу, нам нужно озаботиться сохранением контекста таймеров.


    В моём, сравнительно небогатом, опыте, я встречал следующее решение этой проблемы. Внутри каждого объекта с логикой, завязанной на время, создаются свои таймеры, а при уничтожении объекта они очищаются. Недостаток этого решения в том, что все таймеры нужно очищать вручную.


    Ещё один пример. Если игровое пространство состоит из множества небольших локаций (вспомним, к примеру, The Binding of Isaac), мы можем захотеть хранить состояния объектов и юнитов в некоторых из них. Опять же, нужно прописать возможность поставить таймеры на паузу для каждого юнита.


    timestore


    Основная идея — создать «пространство таймеров» которое можно целиком ставить на паузу, возобновлять, очищать и переиспользовать. Первая, примитивная версия появилась уже в процессе разработки игры, а за прошедшие выходные я написал более адекватный вариант — timestore.


    В качестве таймеров используются два класса: Timeout и Interval. Внутри оба используют setTimeout(). У обоих классов есть методы с говорящими названиями: .clear(), .pause(), .resume() и ещё некоторые. Таймеры можно использовать напрямую, но основная фишка — в классе Timestore.


    Когда мы создаём таймер через timestore, он сохраняется в коллекцию, и после к нему можно обратиться не только напрямую, но и по ID:


    var gameTimers = new timestore.Timestore(),
        simpleTimeout = gameTimers.setTimeout(callback, 5000),
        timeoutWithCustomId = gameTimers.setTimeout('customId', callback, 5000);
    
        someButton.on('click', function () {
            simpleTimeout.clear();
            gameTimers.clearTimeout('customId');
        });

    Важно: нельзя использовать числа (и строки наподобие '10') в качестве кастомных ID, поскольку числа используются как дефолтные идентификаторы внутри timestore.


    Управление целыми коллекциями:


    gameTimers.pauseAll();
    menuTimers.resumeAll();

    Если таймеру передать флаг fireBeforeClear, то в момент очистки он сработает:


    var lightBulb = new Interval(toggleLight, 100, true);
    
    function switchOff() {
        lightBulb.clear(); // В этот момент свет переключится в последний раз.
    }

    Ещё один полезный метод — .getTimeLeft(). Он возвращает количество милисекунд до следующего срабатывания таймера.


    Что дальше?


    В планах на ближайшие два-три дня:


    • добавить проверку пользовательских ID, чтобы нельзя было передать число;
    • написать методы типа .clearIntervals(['id1', 'id3', 'id5']), чтобы было удобнее управлять частями коллекции;
    • добавить Interval.fireCounter — свойство, показывающее, сколько раз сработал таймер;
    • и родственный метод Interval.clearIn(times), позволяющий очистить таймер через несколько срабатываний.

    Также в ближайшее время выложу пару примеров на JSFiddle и CodePen.
    В данный момент есть страница с примерами.


    PS. Кстати, делая timestore, я впервые использовал тесты и, для тех кто ещё сомневается, скажу: тесты — это круто!


    PPS. Прежде чем начать писать код, я попытался найти аналоги, но не встретил ничего похожего. Может быть, я просто плохо искал, а они есть? Поделитесь, подалуйста, если встречали что-то подобное.


    PPPS. Про состояния, которые states, можно подробнее почитать здесь.

    Поделиться публикацией

    Комментарии 21

      +9
      В играх часто удобнее обходиться одним таймером с самым высоким разрешением для выбранного FPS (например, 60 тиков в секунду), который дёргает все модели _активной сцены_, которые внутри себя сами принимают решение о том, на какой тик и как реагировать. Так игровая логика получается более детерминированной (что особенно важно для игр «с физикой»). Т.е., лучше абстрагироваться не до «пространств таймеров», а до «сцен».
        +2

        есть же requestAnimationFrame?

          +4
          Да, для реализации этого единственного таймера нужно использовать requestAnimationFrame, в хэндлере проверяя, сколько прошло времени и пора ли уже «тикать».
          0

          Да, так тоже можно делать, но зачастую у объектов довольно много аспектов поведения, и проверять их все в каждом тике получается накладно по производительности.


          То есть, допустим, общие аспекты, вроде гравитации, стоит применять в Object.tick(), а поведение, повторяющееся через какой-то промежуток времени, вроде атаки вражеского юнита в платформерах, удобно вынести в отдельный метод и управлять им через отдельный таймер.

            +3
            Скорее всего получится наоборот незначительно меньше операций процессора, если тик всё равно отправляется в «общий аспект объекта» (проверки на наступление нужного тика в объекте versus проверка внутри евентлупа JS-движка + вызов асинхронной функции). Нативные таймеры в JS вовсе не «бесплатные». В любом случае, в этом месте вы вряд ли упрётесь в производительность (а если упрётесь, то будете вообще менять архитектуру движка или упрощать игровую логику).
              0
              Возможно, и так. Думаю, стоит написать бенчмарк, чтобы проверить. Займусь этим после выходных.
              0

              Ужасно запутаетесь на асинхроне. Вспоминая схемотехнику, все действия там проходят по фронту задающего сигнала не просто так. Если у вас половина процессора будет работать по одному тактовому сигналу, а вторая — по другому — очень быстро начнется жуткий рассинхрон и потеря данных.


              Ну и да, в отличие от requestAnimationFrame с подсчетом прошедшего времени руками, setTimeout крайне не стабильный по времени. Если так хочется — можно сделать подобную библиотеку в виде обертки над самим requestAnimationFrame.


              Ну и да, все игровые движки, и книги, и вообще мануалы по созданию игр крутятся поверх так называемой game loop, в котором даже если и параллельно, однако обрабатывается все синхронно

                +1

                В схемотехнике по фронту происходят не действия — а фиксация изменений в триггерах.


                Хотелось бы увидеть пример "жуткого рассинхрона и потери данных" в js.

                  0

                  Даже если будут потери в 5-10 мс, это не критично для поведения, описанного выше. Естественно, все движения юнитов и коллизии должны считаться в game loop. Что касается путаницы, то можете глянуть код моего проекта из предыдущего поста. Мне кажется, там всё достаточно ясно.

              +2
              А зачем создавать таймеры для каждого объекта? Вижу в этом смысл, только при разном интервале обновления объектов, но мне кажется, что в этом мало смысла. Не проще ли текущей сцене (меню или уровень) при обновлении сцены запускать функции обновления своих объекты, если они активны, а обновление текущей сцены запускать из одного единственного таймера? Если объект или сцена удалены, он уже никак не обновится, до кучи можно добавить им свойство enable и проверку на него сделать.

              Переключение гл. меню/настройки/игра/уровни легко реализовать самописным сценменеджером, в нем же хранить ссылку на текущую сцену, лучше на две сцены, текущую и предыдущую используемую (возможна ситуация, что они обе должны обновляться, например скролинг он уровня к меню, когда мы видим сперва кусочек уровня и меню, а только потом меню целиком). И тогда вместо
              gameTimers.pauseAll();
              menuTimers.resumeAll();
              

              будет что-то типа
              sceneManager.SetScene(menu);
              

              где
              SetScene = 
              function(scene) {
              this.OldScene = this.CurrentScene;
              this.CurrentScene = scene;
              }
              

              А в таймере вызывать обновление sceneManager, который обновит нужные сцены.
                0
                В том-то и дело, что определённое поведение повторяется через разные промежутки времени. Допустим, есть юнит-матка, который генерирует юнита-пчелу каждые 10 секунд и выстреливает вокруг себя кислотой каждые 4 секунды. Если засунуть эти действия в функцию обновления сцены, то при 60 fps в 10-секундном цикле будет 599 холостых проверок вида «а не пора ли нам сгенерировать пчелу». Если же поведение юнита ещё сложнее, «пустых» проверок будет ещё больше.
                0

                Да, я тоже за один общий таймер. А скорость объектов не дискретная.

                  0
                  Я выше написал, когда может быть удобно использовать отдельные таймеры. А про недискретную скорость не понял, можете пояснить?
                  0

                  Почему вам не нравятся обещания (Promises)? С ними же удобнее делать сложные последовательности анимаций.

                    0

                    Если честно, я просто не хочу усложнять. Возможно, позже, через пару версий, добавлю методы, возвращающие Promise. Может, сделаете форк? Что-то в духе timestore-promises?

                    0

                    Заметил у вас еще одну проблему: ваша функция setTimeout не является совместимой со стандартной.


                    То есть если захочется добавить возможность "заморозки" в существующий код — не получится просто пройтись по коду и заменить все setTimeout на какие-нибудь ts.setTimeout


                    Из отличий — ваша функция не умеет принимать параметры для функции обратного вызова и не умеет выполнять строку.

                      0

                      Вы про эти — setTimeout(calback, delay, [arg1], [arg2], [arg3]) — аргументы?

                        0

                        Да

                          0

                          Каюсь, тут я опирался на старый стандарт. Скорее всего, добавлю метод .callWith(arg1, arg2, ...), который можно будет использовать в цепочке:


                          var t = ts.setTimeout(callback, 100).callWith(arg1, arg2);
                          t.pause(); // Метод .callWith() возвращает объект таймера.
                      0
                      Хорошо, что конкретно для анимации можно использовать WebAnimations, где можно управлять всеми состояниями
                        +1

                        Я бы для анимации использовал что-то вроде PixiJS.

                      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                      Самое читаемое