Играй, но проверяй: как движок обсчитывает дизайнера +25




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


В этой статье мы рассмотрим один элемент стратегии «Казаки 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.




К сожалению, не доступен сервер mySQL