Разрабатывая игру на Unity я столкнулся с интересной задачей: как сделать расширяемое время действия негативных или положительных эффектов на персонаже.

Вкратце у меня есть персонаж, к которому могут применяться некие эффекты, как ослабление, усиление, повышение скорости, снижение скорости и прочие. Чтобы оповещать игрока о действии того или иного эффекта, в игре предусмотрена статусная строка.

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

image

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

  1. Совсем неправильный: Запускается вторая корутина параллельно первой. Когда первая завершается, она возвращает исходные значения, то есть эффект снимается раньше, чем вторая корутина закончила работу.

    image
  2. Тоже неправильный, но в некоторых случаях приемлемый: Отменять первую корутину и запускать вторую. В этом случае время действия эффекта будет равно времени действия первого эффекта до момента отмены корутины + время действия второй корутины.

    image

Для моей задачи оба методы неприемлемы, так как мне надо, чтобы время действия эффекта продлевалось, чтобы я получил в итоге сумму по времени действия каждого наступившего эффекта вне зависимости от того, сколько раз эффект применялся.

Если персонаж наступает на шипы, у него условно повреждается нога и он не может продолжать двигаться с прежней скоростью. Допустим скорость снижается на 5 секунд. Если через 3 секунды персонаж наступает на другие шипы, то скорость должна быть снижена еще на 5 секунд. То есть 3 секунды прошло, 2 осталось + 5 секунд от новых шипов. Время действия эффекта должно продлиться еще 7 секунд с момента наступления на вторые шипы (10 в целом).

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

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

Dictionary<string, float> statusTime = new Dictionary<string, float>();

Словарь в данном случае выгоднее использования очереди, так как очередь работает по принципам First In First Out, но время действия эффектов разное, а значит статус, который надо снять, может стоять не первым в очереди.

Для этого я добавил три метода.

AddStatus

Добавляем нужный статус в словарь, если такой статус в словаре уже есть, то прибавляем время действия. Если статуса нет, то вычисляем время окончания и добавляем в словарь.

private void AddStatus(string status, float duration)?
{
    if (statusTime.ContainsKey(status))
    {
?            statusTime[status] += duration;
?    }
?    else?
    {
?            float endTime = Time.timeSinceLevelLoad + duration;
?            statusTime.Add(status, endTime);?
    }
}

RemoveStatus

Удаляем статус из словаря, восстанавливаем исходные значения.

private void RemoveStatus(string status)?
{
?        statusTime.Remove(status);
?        RestoreStats(status);?
}

CheckStatus

Если в словаре есть статусы, то проверяем, не истекло ли время их действия.

Если истекло, то удаляем статус из словаря. Так как изменения словаря в цикле приводит к невозможности синхронизации значений словаря, то перекидываем тут ключи словаря в обычный List.

private void CheckStatuses()?
    {
?        if (statusTime.Count > 0)
?        {
?            float currTime = Time.timeSinceLevelLoad;?
            List<string> statuses = new List<string>(statusTime.Keys);
??            foreach (string stat in statuses)?
            {?
                if (currTime > statusTime[stat])?
                {?
                    RemoveStatus(stat);?
                }?
            }?
        }
?    }

Из плюсов, очевидно, это расширяемое время действия эффектов. То есть задача решена.
Но и довольно значительный минус тут присутствует. Проверка на наличие статусов осуществляется в Update каждый фрейм. В моей игре присутствует максимум 4 игрока, а значит этот метод будет выполняться каждый фрейм 4 раза параллельно. При 4 персонажах, я думаю, это не критично, но уверен, что при большем количестве персонажей, это может вызвать проблемы производительности. Стоит так же отметить, что метод защищен проверкой на наличие в словаре элементов, и при пустом словаре нагрузка должна снижаться.

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

Комментарии (17)


  1. Tutanhomon
    13.10.2018 23:05

    Режим зануды
    Серьезно? Статья о том, как зажечь/погасить иконку? О взаимодействии потоков корутин?


    1. Mak5AM Автор
      13.10.2018 23:32

      Спасибо за комментарий. Статья не об иконках. Видимо начал неудачно. Иконки как раз зажигаются и гаснут по событию на персонаже.
      Задача была именно в том, чтобы правильно отслеживать время, сколько эффект должен длиться на персонаже.


  1. Shortki
    14.10.2018 00:23

    В текущей реализации сразу напрашивается CheckStatuses()? сделать корутиной, пробегая по статусам определять минимальный период когда будет смена какого-то из статусов ака minStatusTime и засыпать на это время.


    1. FadeToBlack
      14.10.2018 06:31

      Корутины не работают как потоки, так что ничего сэкономить тут не получится. Только проверок добавится.


  1. rumyancevpavel
    14.10.2018 08:17

    А почему не реализовать каждый «статус» как компонент и не делать например так:

    _character.AddComponent<Curse>()

    Тогда и не нужны корутины — каждый «статус» в своём Start() начинает отсчёт времени, добавляет иконку если ее еще нет, в Update(), изменяет поведение персонажа в зависимости от своей логики и времени действия, а в OnDestroy() заканчивает своё действие.


    1. Mak5AM Автор
      14.10.2018 10:25

      То есть перед добавлением статуса проверять, есть ли уже такой компонент на персонаже и если есть, то добавлять время. А если нет, то добавлять компонент. И компонент сам будет управлять, сколько ему осталось жить.
      Очень любопытно, спасибо за идею. Подумаю над этим.


    1. Snakes911
      14.10.2018 10:31

      Не производительный подход.
      OnDestroy вообще лучше в процессе игры не использовать, особенно на мобильных устройствах, лучше выключать компонент/объект, ибо OnDestroy требовательный + лишняя загрузка GC. Лучше инициировать в начале сцены, и затем использовать OnEnable/OnDisable — но не уверен что вызовы вызываются если только компонент выключается/включается, не было необходимости проверять это.
      Манипулировать уже инициализированными переменными намного проще и быстрее, чем создавать новые компоненты в процессе игры.


      1. Mak5AM Автор
        14.10.2018 10:42

        Можно заранее добавить все компоненты на персонажа. Включать компонент enabled = true. И сделать метод на компоненте, который запускает отсчет времени и эффект. Когда время выходит: отключать эффект, enabled = false.
        Мне кажется это способ аналогичен вашему, так как каждый компонент будет иметь локальную переменную, отвечающую за время, с ограничением на то, что только необходимые компоненты будут включаться.


        1. Snakes911
          14.10.2018 10:46

          Такой вариант тоже жизнеспособен, тут уже много вариантов реализации — какой самый правильный и удобный уже каждый решит сам) Тут просто сам факт что проще завести локальную переменную и работать с ней, чем работать с массивом.


          1. Mak5AM Автор
            14.10.2018 10:59

            Спасибо за ценные идеи/советы! Этот кусок я буду переписывать наверное уже раз в четвертый.


  1. Snakes911
    14.10.2018 10:25

    Если вы все равно проверяете это в Update, то почему нельзя завести переменную которая будет отвечать за время эффекта? Т.е. заводите float SlowTime. Пока она равна 0, то скорость обычная, как только накладывается эффект, то к ней прибавляется 5 (секунд), которые в Update уменьшаются по deltaTime. Как только ставиться 0, возвращаем скорость. Попадает на дополнительный эффект, просто прибавляем к SlowTime еще Х секунд.
    Это будет намного производительнее и проще.


    1. Mak5AM Автор
      14.10.2018 10:36

      У меня была такая мысль. В этом случае в Update будут проверяться переменные статусов, которые могут ни разу не наступить за все время сцены. Это меня и пугает, что за время, проведенное на этой сцене, 8 из 13 эффектов могут не сработать, но в Update на 4х персонажах эти переменные все равно будут проверяться на ноль каждый фрейм.


      1. Snakes911
        14.10.2018 10:37

        Создай Event и подписывай нужный Update только тогда, когда на игрока применился эффект… как только эффект спал, отписывайся от обновления и все. В итоге проверка будет работать только в случае если на игроке какой либо эффект.


      1. Snakes911
        14.10.2018 10:41

        Например у меня в игре, в моем коде, ровно один Update, FixedUpdate и LateUpdate которые запускают каждый свой Event, и в других скриптах где надо я подписываюсь на обновления, а где надо, отписываюсь. Update сами по себе вызываются медленно, и чем меньше их в проекте тем лучше.


      1. SmallSnowball
        14.10.2018 21:28

        13 * 4 = 52 проверки переменной на ноль за кадр это не просто быстро, это фактически бесплатно на данный момент. Я даже не уверен, что поддержка мэпа не обойдется дороже, если там будут постоянно добавляться/удаляться элементы, не знаю, как быстро в шарпе память для элементов мэпа аллоцируется. Пока там не будет хотя бы сотни эффектов на пару десятков персонажей, влияние на производительность даже из статистической погрешности не выберется. Т.е. как способ уменьшения лапши в коде продуманная система баффов/дебаффов это хорошо и удобно, но с т.з. перфоманса скорее всего лучше будет сконцентрировать силы на чем-то еще


  1. CorvOrk
    14.10.2018 10:47

    Советую автору в качестве моделей присмотреться к ESC (https://habr.com/company/pixonic/blog/413729/).


    1. Mak5AM Автор
      14.10.2018 10:53

      Слышал об этом (https://unity3d.com/ru/learn/tutorials/topics/scripting/introduction-ecs?playlist=17117).
      Спасибо за ссылку, почитаю. Пока для меня это Rocket Science. Мне бы в своем болоте порядок навести :)