При дизайне многопользовательской игры чуть ли не самой важной составляющей является баланс. Работа игрового дизайнера в этом плане похожа на работу аналитика спецслужб: если он работает хорошо, никто ничего не замечает. Стоит оступиться, и игроки бессовестно воспользуются ошибкой. Но самое интересное случается, когда помимо гейм-дизайнера ошибается ещё и программист...


В этой статье мы рассмотрим один элемент стратегии «Казаки 3». В игре присутствуют различные виды мушкетёров и иных стрелков 17 и 18 веков, а также возможность исследовать технологии, снижающие время перезарядки мушкетов. Всего имеется два улучшения, каждое из которых приносит +30% к скорострельности — если верить интерфейсу игры.


Но даже на глаз видно, что некоторые боевые единицы после исследования улучшений стреляют не просто на 60%, а даже в несколько раз чаще. При измерении скорострельности непосредственно с помощью встроенного игрового таймера выходят и вовсе странные числа, которые к заявленным процентам никакого отношения не имеют.


Под капотом у «Казаков»


К счастью, игра выполнена в очень дружелюбном для моддеров виде, так что все нужные нам скрипты доступны в виде текстовых файлов в папке data/scripts/. Судя по синтаксису, скрипты написаны на Делфи или на очень похожем языке. Давайте же взглянем на механику расчёта интервалов между выстрелами.


Примечания
  • Анализ проводился на игре «Казаки 3» версии 2.1.4.
  • Все приведённые ниже отрезки скриптов содержат упрощённый псевдокод.

  1. При старте игры происходит инициализация всех боевых единиц. В процедуре указываются значения жизненной силы, стоимости и вооружения для каждого типа. Для стрелкового оружия передаётся параметр, указывающий интервал между выстрелами в игровых кадрах:


    //lib/unit.script
    procedure _unit_InitBase()
        'musketeer' :
            maxhp := 70;
            SetObjBaseWeapon( x,x,x,x, 150, ... );
            SetObjBasePrice( ... );
    
    //lib/unit.script
    procedure SetObjBaseWeapon( x,x,x,x, pause, ... )
        weapon.pause := _misc_FramesToTime( pause );

    Судя по комментариям, единица времени «игровой кадр» это атавизм из первых «Казаков», игровой процесс которых был скопирован при создании третьей части. Впрочем, кадры сразу же пересчитываются в игровые секунды с соотношением 1:32, и больше мы с ними не сталкиваемся:


    //lib/misc.script
    function _misc_FramesToTime( val )
       Result := ( val * gc_frames_to_time );
    
    //dmscript.global
    gc_frames_to_time := 0.03125;
    gc_time_to_frames := 32;

  2. Также при старте игры инициализируются данные игровых наций, включая доступные улучшения. Для каждого из них указывается и сохраняется переменная value, которая при исследовании этого улучшения влияет на перерасчёт нужных параметров игры:


    //lib/country.script
    procedure _country_Init()
        _country_AddUpgrade( x,x,x,x, type_attpauseperc, -30, ... );
    
    procedure _country_AddUpgrade( x,x,x,x, upgrade_type, value, ... );

    В нашем случае это означает, что интервалы боевых единиц после каждого улучшения умножаются на 0,7 а затем… округляются?!


    //lib/player.script
    procedure _player_ApplyUpgrade()
        type_attpauseperc :
            weapon.pause := Round( weapon.pause * (1 + value/100) );

    Учитывая то, что изначально интервалы стрелков представляют собой числа с плавающей запятой в диапазоне от 3,125 до 5,0, решение округлять результат перерасчёта выглядит довольно странно, если не сказать бажно.


  3. После каждого произведённого выстрела указывается задержка перед следующим выстрелом. Модификатор idividual.attackrate применяется к башенным сооружениям и в нашем случае всегда равен 1.


    //lib/unit.script
    procedure _unit_ApplyAttackPause()
        attackdelay := weapon.pause * idividual.attackrate;


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


Немного математики

Величина скорострельности обратно пропорциональна величине интервала между выстрелами. И если для игрока важно именно количество выстрелов в минуту, то игровой движок, как правило, использует интервалы для подсчёта паузы. Подвох здесь в том, что «снизить интервал на 30%» и «повысить скорострельность на 30%» это совершенно разные вещи. Соотношение r между интервалами t и количествами выстрелов n описывается простой формулой:

$\frac{t_1}{t_2}=r=\frac{n_2}{n_1}$


Если, например, взять интервал в 6 секунд (10 выстрелов в минуту) и уменьшить его на 30%, то мы не получим 13 выстрелов в минуту:

$6\space\mathrm{s}\cdot0.7=4.2\space\mathrm{s};\quad\frac{6\space\mathrm{s}}{4.2\space\mathrm{s}}\approx1.43\neq\frac{13}{10}$


Чтобы получить нужную величину, следует разделить текущий интервал на желаемое соотношение новой скорострельности к старой:

$t_2=\frac{t_1}{r}=\frac{6\space\mathrm{s}}{1.3}\approx4.62\space\mathrm{s}$



Метод измерения

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


    //cossacks.ini & editor.ini
    LogFileEnabled = true
    LogFileRoot = true

А затем добавить в конце процедуры _unit_ApplyAttackPause() вызов функции Log():


    //data/scripts/lib/unit.script
    procedure _unit_ApplyAttackPause(const goHnd, weapind : Integer);
    begin
        //...
        if (attpause<>0) then
        Log(TObjProp(pobjprop).sid+' '+FloatToStr(attpause));
    end;

Теперь можно поиграться с различными стрелками и улучшениями в редакторе карт (для включения режима нападения следует нажать Ctrl+W). Протокол будет записан в текстовый файл в папке /log. После каждого произведённого выстрела будет записан идентификатор боевой единицы и величина её текущего интервала.


Who is who


Изначально скрипты игры различают 35 видов стрелков (не считая наёмников, которые не подвержены воздействию улучшений). Если сгруппировать их всех по величине интервала, то мы сможем выделить десять категорий. Я решил сортировать их по относительному приросту скорострельности, чтобы выделить тех стрелков, кто больше других выигрывает от улучшений. Итак, результаты анализа:


Интервал атаки Выстрелы / мин Рост скорострельности
Категория$\backslash$Улучшения 0 +1 +2 0 +1 +2 +1 +2
I 5,00 4,0 3,0 12,0 15 20 +25% +67%
II 6,88 5,0 4,0 8,7 12 15 +38% +72%
III 5,31 4,0 3,0 11,3 15 20 +33% +77%
IV 5,63 4,0 3,0 10,7 15 20 +41% +88%
V 3,75 3,0 2,0 16,0 20 30 +25% +88%
VI 5,94 4,0 3,0 10,1 15 20 +48% +98%
VII 4,06 3,0 2,0 14,8 20 30 +35% +103%
VIII 4,38 3,0 2,0 13,7 20 30 +46% +119%
IX 4,69 3,0 2,0 12,8 20 30 +56% +134%
X 3,13 2,0 1,0 19,2 30 60 +56% +213%

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


Список категорий и боевых единиц

В игре присутствуют различные нации — 17 европейских и четыре уникальных (Украина, Турция, Алжир и Шотландия). Европейские фракции изначально очень похожи и имеют мушкетёров и драгун 17—18 вв., а также гренадёров. Но иногда стрелки некоторых наций отличаются от шаблонных, или же вовсе заменены уникальным типом.


Категория Боевые единицы
I Мушкетер 17в. (Австрия)
Секей (Венгрия)
Шотландский стрелок (Англия)
Посполитое рушение (Польша)
Драгун 18в. (Нидерланды и Пьемонт)
II Егерь (Швейцария)
Королевский мушкетер (Франция)
III Гренадер (Европа кроме Дании и Пруссии)
Драгун 18в. (Европа кроме Франции, Нидерландов и Пьемонта)
Легкий кавалерист (разные страны)
IV Драгун 17в. (Европа)
V Мушкетер 17в. (Нидерланды)
VI Мушкетер 17в. (Испания)
Мушкетер 18в. (Бавария и Дания)
Гренадер (Дания)
Доброволец (Португалия)
Егерь (Франция)
VII Сердюк (Украина)
VIII Мушкетер 18в. (Саксония)
Гренадер (Пруссия)
IX Мушкетер 17в. (Европа кроме Австрии, Польши, Нидерландов и Испании)
Мушкетер Ковенанта (Шотландия)
Стрелец (Россия)
Янычар (Турция)
Мушкетер 18в. (Европа кроме Дании, Баварии и Саксонии)
Пандур (Австрия)
Драгун 18в. (Франция)
X Мушкетер 17в. (Польша)
Гайдук (Венгрия)

Примечания:


  • Наименования боевых единиц скопированы из русского интерфейса игры.
  • Курсивом выделены стрелки 18 века.
  • Жирным шрифтом выделены конные стрелки.

Оказывается, больше всех от улучшений скорострельности выигрывают польский мушкетёр 17 века и венгерский гайдук: вместо обещанных +60% они стреляют более чем в три раза чаще. Благодаря низкому изначальному значению интервала они в итоге стреляют быстрее всех остальных стрелков в два, три, а то и четыре раза.


Среди кавалерии лучше всего устроились французские драгуны 18 века: они получают прирост скорострельности более чем в два раза. В итоге они совершают на 50% больше выстрелов в минуту, чем их коллеги из остальных европейских наций.


Естественно, здесь не учитывается урон выстрела или урон в секунду, но даже и без этих данных очевидно, что боевые единицы ведут себя не так как задумано.


Как исправить

Самое быстрое и неинвазивное решение проблемы это переписать формулу применения улучшения. Кроме отказа от округления следует вместо умножения интервала на 0,3 поделить его на 1,3. Для этого достаточно заменить в процедуре обработки улучшения gc_upg_type_attpauseperc формулу с


    //lib/player.script
    Round(weapon.pause*(1+value/100));

на


    weapon.pause/(1+(-value)/100);

Так как улучшения применяются последовательно, в итоге вместо заявленных +60% мы получим +69%. Но это всё же лучше чем +213%.


Послесловие


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


Идею для исследования я почерпнул из видеоролика «Why Attack Rates in AoE2 Are Often Wrong» (англ.), рассматривающего схожую проблематику в стратегии Age of Empires II.

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


  1. datacompboy
    21.06.2018 22:55
    +1

    ох уж эти проценты :D


  1. ragequit
    22.06.2018 08:45

    Думал я все же прикупить казаков на распродаже, сейчас за 5$ стоят, но что-то как-то…


  1. LAG_LAGbI4
    22.06.2018 09:32
    -1

    А вам не кажется, что это какая-то дичь, что написано одно, а в игре всё происходит по другому. Как в такие игры играть, как просчитывать стратегию?


    1. vesper-bot
      22.06.2018 10:51

      Как в дьябле-2 — проверять, что есть по факту, потом учитывать то, что в реальности происходит, а не то, что написано в мануале. Там подобных косяков (местами именно косяков, а не просто фич вроде EIAS threshold или багофич вроде mastery amplification при атаке врукопашную, если атакующий с этой же mastery на себя энчант кастил) воз и маленькая тележка.


      1. Ereb Автор
        22.06.2018 14:19

        В данном случае следует учитывать, что скрипты игры до сих пор активно дорабатываются (что не может не радовать). Например, в версии 2.0.8 файл unit.script имел размер в 131 KB, а в анализированной здесь версии 2.1.4 — 546 KB.


  1. vesper-bot
    22.06.2018 09:45

    Опять арифметическое складывание в знаменателе вида Y=X/(1-A-B). Этим грешат примерно половина* разработчиков, которые получают на вход задание вида «на A% чаще/быстрее», интерпретируют его как «задержка становится на A% короче» и игнорируют изменения соседних фич такого же класса. Тут ещё и усугубили округлениями (так что тики-таки остались, внутренняя механика продолжает считаться в тиках).


    1. AngReload
      22.06.2018 10:08
      +1

      Мне интересно, к чему приведёт отказ от округления? Из публикации не особенно понятно как юнит решает что пора сделать выстрел?


      Если там используется некоторый параметр времени отката, который обнуляется при выстреле, то из-за дискретных тиков реально он будет округляться как floor или ceil, что может быть хуже чем round.


      Или вдруг там делается сравнение с целочисленными тиками, и из-за дробного интервала проверка никогда не вернёт true и стрельба станет невозможной.


      1. vesper-bot
        22.06.2018 10:48

        Смотря в самом деле, как именно запилена логика разрешения стрельбы. Я, когда TD пилил, делал целочисленный аккумулятор, в который каждый тик прибавлялась скорость стрельбы, здесь явно не так. Скорее всего, здесь применена логика с параметром «время до следующего выстрела», которое подтягивается из таблицы типов юнитов, куда записываются данные после проведенных исследований, например, и время это целое (с шансом 95%, остальные 5% на вещественное и логику сравнения «меньше нуля» при проверке разрешения на стрельбу). Т.е. параметр времени отката есть, при проверке сравнивается с нулем, и при выстреле заполняется значением из таблицы, которое при отсутствии округления по умолчанию будет работать как floor. ИМХО.


      1. vesper-bot
        22.06.2018 11:23

        Ну как минимум, одно из округлений надо точно убирать — нормально прочитал таблицу и увидел, что округление идет до секунд вместо тиков, и вот это невыразимый пипец.


    1. Ereb Автор
      22.06.2018 14:08

      внутренняя механика продолжает считаться в тиках

      Насколько я понял, тики всё же атавизм, движок третьих Казаков работает с секундами. Скорее всего, раньше параметр интервала хранился в тиках и пересчитывался в секунды непосредственно перед применением в _unit_ApplyAttackPause(). Потом в каком-то обновлении, скорее всего, изменили логику и стали хранить интервалы сразу в секундах, но вот код применения апгрейда оставили тем же.


      1. vesper-bot
        22.06.2018 14:18
        +2

        Тогда понятно, зачем округление, и пока вся механика была в тиках, оно было нужно. А как на секунды поменяли, о нем не вспомнили, а эффект от него в 32 раза больше, чем на тиках, в результате исходные 1-2% погрешности выросли в 30-60%, и при двукратном применении добрались до 210%. Нормальная поддержка легаси-кода, baseline сменили, выстрелило в ногу.


        1. Ereb Автор
          22.06.2018 14:47

          Согласен. Ещё меня интересует, как это осталось незамеченным.


  1. Dart_Zaiac
    22.06.2018 13:15
    +1

    Производитель газировки думает привлечь новых клиентов и имеет два варианата:
    1) Увеличить объем воды на 10% со старой ценой.
    2) Уменьшить стоимость на 10% при том-же объеме.
    Что для покупателя выгоднее?


    1. mikhailro
      22.06.2018 15:29
      +1

      очевидно уменьшить стоимость.
      всегда для подобных задач/вопросов меняю цифры на граничные и сравниваю результат)
      например, если уменьшить цену на 99% — получим в 100 раз меньше цену, увеличим объём на 99% — получим почти в два раза больше воды.

      это просто, что б на калькуляторе не считать, а интуитивно сразу ответить)


      1. Dart_Zaiac
        22.06.2018 16:48

        Да, я так-же решил. Это всего-лишь пример разницы "*1,3" и "/0,7"


        1. Ereb Автор
          22.06.2018 17:14

          Ага. Мне понравилось, что в Age of Empires II интерфейс заявляет у японцев +25% к скорости атаки пехоты, а на деле их самураи рубят на 33% чаще. Должно было быть t/1.25, а сделали t*0.75.


  1. Sensative
    22.06.2018 13:58

    А потом: «Математика программистам не нужна»


  1. Ereb Автор
    22.06.2018 18:59

    Вчера вышло обновление 2.1.5, есть изменения в балансе. По всей видимости, у юнитов категории X после улучшений интервал становится меньше времени анимации. Это приводит к тому, что стрелки подвисают после первого выстрела и перестают стрелять: видео.