При дизайне многопользовательской игры чуть ли не самой важной составляющей является баланс. Работа игрового дизайнера в этом плане похожа на работу аналитика спецслужб: если он работает хорошо, никто ничего не замечает. Стоит оступиться, и игроки бессовестно воспользуются ошибкой. Но самое интересное случается, когда помимо гейм-дизайнера ошибается ещё и программист...
В этой статье мы рассмотрим один элемент стратегии «Казаки 3». В игре присутствуют различные виды мушкетёров и иных стрелков 17 и 18 веков, а также возможность исследовать технологии, снижающие время перезарядки мушкетов. Всего имеется два улучшения, каждое из которых приносит +30% к скорострельности — если верить интерфейсу игры.
Но даже на глаз видно, что некоторые боевые единицы после исследования улучшений стреляют не просто на 60%, а даже в несколько раз чаще. При измерении скорострельности непосредственно с помощью встроенного игрового таймера выходят и вовсе странные числа, которые к заявленным процентам никакого отношения не имеют.
Под капотом у «Казаков»
К счастью, игра выполнена в очень дружелюбном для моддеров виде, так что все нужные нам скрипты доступны в виде текстовых файлов в папке data/scripts/. Судя по синтаксису, скрипты написаны на Делфи или на очень похожем языке. Давайте же взглянем на механику расчёта интервалов между выстрелами.
- Анализ проводился на игре «Казаки 3» версии 2.1.4.
- Все приведённые ниже отрезки скриптов содержат упрощённый псевдокод.
При старте игры происходит инициализация всех боевых единиц. В процедуре указываются значения жизненной силы, стоимости и вооружения для каждого типа. Для стрелкового оружия передаётся параметр, указывающий интервал между выстрелами в игровых кадрах:
//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;
Также при старте игры инициализируются данные игровых наций, включая доступные улучшения. Для каждого из них указывается и сохраняется переменная 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, решение округлять результат перерасчёта выглядит довольно странно, если не сказать бажно.
После каждого произведённого выстрела указывается задержка перед следующим выстрелом. Модификатор idividual.attackrate применяется к башенным сооружениям и в нашем случае всегда равен 1.
//lib/unit.script procedure _unit_ApplyAttackPause() attackdelay := weapon.pause * idividual.attackrate;
Итак, помимо математической ошибки в расчётах, детали которой можно прочитать под спойлером ниже, налицо неуместное округление чисел с плавающей запятой. Интересно, какой эффект на механику игры имеет эта на первый вздляд незначительная оплошность?
Величина скорострельности обратно пропорциональна величине интервала между выстрелами. И если для игрока важно именно количество выстрелов в минуту, то игровой движок, как правило, использует интервалы для подсчёта паузы. Подвох здесь в том, что «снизить интервал на 30%» и «повысить скорострельность на 30%» это совершенно разные вещи. Соотношение r между интервалами t и количествами выстрелов n описывается простой формулой:
Если, например, взять интервал в 6 секунд (10 выстрелов в минуту) и уменьшить его на 30%, то мы не получим 13 выстрелов в минуту:
Чтобы получить нужную величину, следует разделить текущий интервал на желаемое соотношение новой скорострельности к старой:
Чтобы получить значения, с которыми работает движок игры, можно воспользоваться функциями протоколирования. Для этого сначало нужно включить запись лога:
//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 видов стрелков (не считая наёмников, которые не подвержены воздействию улучшений). Если сгруппировать их всех по величине интервала, то мы сможем выделить десять категорий. Я решил сортировать их по относительному приросту скорострельности, чтобы выделить тех стрелков, кто больше других выигрывает от улучшений. Итак, результаты анализа:
Интервал атаки | Выстрелы / мин | Рост скорострельности | ||||||
КатегорияУлучшения | 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)
ragequit
22.06.2018 08:45Думал я все же прикупить казаков на распродаже, сейчас за 5$ стоят, но что-то как-то…
LAG_LAGbI4
22.06.2018 09:32-1А вам не кажется, что это какая-то дичь, что написано одно, а в игре всё происходит по другому. Как в такие игры играть, как просчитывать стратегию?
vesper-bot
22.06.2018 10:51Как в дьябле-2 — проверять, что есть по факту, потом учитывать то, что в реальности происходит, а не то, что написано в мануале. Там подобных косяков (местами именно косяков, а не просто фич вроде EIAS threshold или багофич вроде mastery amplification при атаке врукопашную, если атакующий с этой же mastery на себя энчант кастил) воз и маленькая тележка.
Ereb Автор
22.06.2018 14:19В данном случае следует учитывать, что скрипты игры до сих пор активно дорабатываются (что не может не радовать). Например, в версии 2.0.8 файл unit.script имел размер в 131 KB, а в анализированной здесь версии 2.1.4 — 546 KB.
vesper-bot
22.06.2018 09:45Опять арифметическое складывание в знаменателе вида Y=X/(1-A-B). Этим грешат примерно половина* разработчиков, которые получают на вход задание вида «на A% чаще/быстрее», интерпретируют его как «задержка становится на A% короче» и игнорируют изменения соседних фич такого же класса. Тут ещё и усугубили округлениями (так что тики-таки остались, внутренняя механика продолжает считаться в тиках).
AngReload
22.06.2018 10:08+1Мне интересно, к чему приведёт отказ от округления? Из публикации не особенно понятно как юнит решает что пора сделать выстрел?
Если там используется некоторый параметр времени отката, который обнуляется при выстреле, то из-за дискретных тиков реально он будет округляться как floor или ceil, что может быть хуже чем round.
Или вдруг там делается сравнение с целочисленными тиками, и из-за дробного интервала проверка никогда не вернёт true и стрельба станет невозможной.
vesper-bot
22.06.2018 10:48Смотря в самом деле, как именно запилена логика разрешения стрельбы. Я, когда TD пилил, делал целочисленный аккумулятор, в который каждый тик прибавлялась скорость стрельбы, здесь явно не так. Скорее всего, здесь применена логика с параметром «время до следующего выстрела», которое подтягивается из таблицы типов юнитов, куда записываются данные после проведенных исследований, например, и время это целое (с шансом 95%, остальные 5% на вещественное и логику сравнения «меньше нуля» при проверке разрешения на стрельбу). Т.е. параметр времени отката есть, при проверке сравнивается с нулем, и при выстреле заполняется значением из таблицы, которое при отсутствии округления по умолчанию будет работать как floor. ИМХО.
vesper-bot
22.06.2018 11:23Ну как минимум, одно из округлений надо точно убирать — нормально прочитал таблицу и увидел, что округление идет до секунд вместо тиков, и вот это невыразимый пипец.
Ereb Автор
22.06.2018 14:08внутренняя механика продолжает считаться в тиках
Насколько я понял, тики всё же атавизм, движок третьих Казаков работает с секундами. Скорее всего, раньше параметр интервала хранился в тиках и пересчитывался в секунды непосредственно перед применением в _unit_ApplyAttackPause(). Потом в каком-то обновлении, скорее всего, изменили логику и стали хранить интервалы сразу в секундах, но вот код применения апгрейда оставили тем же.
vesper-bot
22.06.2018 14:18+2Тогда понятно, зачем округление, и пока вся механика была в тиках, оно было нужно. А как на секунды поменяли, о нем не вспомнили, а эффект от него в 32 раза больше, чем на тиках, в результате исходные 1-2% погрешности выросли в 30-60%, и при двукратном применении добрались до 210%. Нормальная поддержка легаси-кода, baseline сменили, выстрелило в ногу.
Dart_Zaiac
22.06.2018 13:15+1Производитель газировки думает привлечь новых клиентов и имеет два варианата:
1) Увеличить объем воды на 10% со старой ценой.
2) Уменьшить стоимость на 10% при том-же объеме.
Что для покупателя выгоднее?mikhailro
22.06.2018 15:29+1очевидно уменьшить стоимость.
всегда для подобных задач/вопросов меняю цифры на граничные и сравниваю результат)
например, если уменьшить цену на 99% — получим в 100 раз меньше цену, увеличим объём на 99% — получим почти в два раза больше воды.
это просто, что б на калькуляторе не считать, а интуитивно сразу ответить)Dart_Zaiac
22.06.2018 16:48Да, я так-же решил. Это всего-лишь пример разницы "*1,3" и "/0,7"
Ereb Автор
22.06.2018 17:14Ага. Мне понравилось, что в Age of Empires II интерфейс заявляет у японцев +25% к скорости атаки пехоты, а на деле их самураи рубят на 33% чаще. Должно было быть t/1.25, а сделали t*0.75.
datacompboy
ох уж эти проценты :D