
Это вторая часть разных трюков, хаков и интересных механик, которые я вспомнил, пока писал статью про "coyote time", но там казалось, что это такой маленький и почти невинный трюк, который живёт где-то на краю платформы и больше нигде не встречается. На самом деле это только самый заметный кусочек довольно большой кухни игростроя, потому что в рандоме, в поведении врагов, в хитбоксах и даже в том, где именно стоят ноги персонажа приходится делать разные допущения не из вредности, просто честная симуляция ощущается хуже, чем подкрученная. Игрок не видит формул, зато видит последствия, и часто последствия выглядят несправедливо, и никакие объяснения про корректный генератор случайных чисел игрока не убедят что игра читит.
Забавнее другое, когда жалобы прилетают как раз на честные системы и если вы когда-нибудь читали форумы любой игры с выпадением лута, то точно видели там темы в духе “шанс 10% это враньё, я убил босса сорок раз и ничего не выпало”. И вот потом автор такой темы начинает бобмить в ревью своими ощущениями как надо, но он неправ в своих выводах и при честных 10% сорок промахов подряд случаются примерно у одного игрока из семидесяти, а игроков у вас, допустим, сто тысяч и вот он сидит такой несчастный среди своих семидясети товарищах по "рандому" и грустит.
Сид Мейер как-то рассказывал, как в Civilization игроки отказывались принимать честные шансы боя, при 3 к 1 в свою пользу проигрыш воспринимался как обман, потому что игрок считает что если 3 к 1, значет каждый второй бой будет в его пользу. Ну просто примите, люди когда видят циферки в играх, всегда их трактуют в свою сторону. И в итоге в Циве пришлось подкручивать шансы так, чтобы они совпадали не с математикой, а с ожиданиями людей.
Врём про случайность
Человек очень плохо понимает, что такое случайность, и это проблема не человека, а разработчиков игры, и это давно известный факт из психологии. Мы ожидаем, что случайная последовательность будет “перемешанной в нашу пользу”, без длинных серий, без кучек, без повторов. Вот только настоящий рандом устроен вообще наоборот, и "честно" выдаёт серии, кучки и повторы, просто потому что ему всё равно, что было раньше, поэтому почти любой генератор случайности в игре, который должен ощущаться честным, на самом деле подкручен в пользу игрока. Почему? Потому что люди хотят выигрывать.

Самый простой и самый распространённый трюк, когда у предмета написан шанс выпадения 10%, и "правильная" реализация выглядит так:
bool RollDrop(float chance) {
return Random01() < chance;
}
Но у такого броска нет памяти, и он не знает, что игрок уже промахнулся тридцать раз подряд, и на тридцать первый раз у него снова теже 10%, а вероятность серии из N промахов подряд падает экспоненциально, но никогда не становится нулём. Если прогнать сто тысяч бросков, худшая серия промахов будет где-то в районе 70–80, и для того игрока, которому эта серия досталась, ваша игра навсегда останется “сломанной”.
Pity timer решает это расчитом промахов, когда после определённого порога шанс начинает расти, а на некотором значении выпадение становится гарантированным. Gacha-игры довели это до системы, где есть soft pity, когда шанс резко растёт после нескольких десятков попыток, и hard pity, когда предмет выпадет гарантированно на 90-й попытке, если не выпал раньше. 90 тоже не взялось с потолка и это предел "человеческого ожидания" факта, взятый из анализа работы игровых автоматов, которые приносят больше всего дохода казино. Вот как-то так оно выглядит:
float PityChance(int misses) {
if (misses + 1 >= hardPity) return 1.0f;
if (misses < softPityStart) return baseChance;
float t = float(misses - softPityStart + 1) / float(hardPity - 1 - softPityStart);
return baseChance + (1.0f - baseChance) * t;
}
pity врёт игроку, делая выигрыш более частым, и реальная частота выпадения при таком правиле выше той, что написана в описании предмета. В примере с 10% на экране, ростом шанса после восьмого промаха и гарантией на восемнадцатой попытке реальный шанс получается около 14%, а худшая серия промахов на ста тысячах бросков обрезается до 16 вместо 78 у честного кубика.
Есть и более умные варианты, который придумали для Warcraft III и который до сих пор цветет и пахнет в Dota 2. Называется эта гача pseudo-random distribution, PRD и шанс на N-й попытке после последнего успеха равен C × N (синяя колонка), где C подобрано так, чтобы среднее совпадало с заявленным шансом. Теперь для предмета с 10% (константа примерно 0.01475), первый удар после крита имеет всего полтора процента шанса критануть снова, а десятый уже почти 15% и игроки считают это одной из самых справедливых систем рандома.
float PrdChance(int misses) {
return min(1.0f, C * float(misses + 1)); // C = 0.01475 для 10%
}
PRD хорош тем, что он "человечески честен" в среднем, и если вы будете считать на большой дистанции, то получите те самые 10%. Но он почти не даёт длинных серий ни в одну сторону, ни серий неудач, ни серий удач, и сто тысяч бросков дают худшую серию промахов около 30, поэтому игроки считают его правдивым.
Попробовать самому: Три игрока кидают один и тот же кубик, у Pity и PRD шанс растёт с каждым промахом.
Кубики урона и true hit
Второй источник ощущения несправедливости в рандоме, это сама форма распределения. Возьмём, к примеру, урон оружия (1–12) и сделаем его честно, одним броском d12, тогда средний урон будет 6.5, и каждое значение от 1 до 12 будет выпадать одинаково часто, т.е. удар на единичку будет происходить в каждом двенадцатом ударе. Но 1 это фейл и игрок его обязательно запомнит, и скажет что игра нечестная, поэтому мы бросаем 2d6, получая почти тот же диапазон, но среднее будет уже 7, и распределение превращается в треугольник, где единица не выпадает никогда, двойка выпадает в одном случае из тридцати шести, т.е. в три раза реже, чем 1 у d12. Честно ли это? А вы спросит и дндщиков, какой из кубиков честнее 2d6 или 1d12.
Но это ещё не враньё, просто мы правильно выбрал формулу и правильно объяснили её игроку, а враньё начинается в Fire Emblem, когда начиная с Binding Blade игра показывает шанс попадания, допустим 70%, но под капотом кидает не один случайный номер от 0 до 99, а два, и берёт их среднее:

Среднее двух равномерных чисел тяготеет к середине, и поэтому высокие шансы становятся ещё выше, а низкие ещё ниже, поэтому при 70% на экране реальный шанс попасть около 82%. Сообщество игры называет это true hit, и разработчики никогда не писали об этом на публике, потому что игрок интуитивно и так переоценивает высокие шансы и недооценивает низкие, и система просто подыгрывает вашим ожиданиям, наверное поэтому там так интересно выигрывать, а промах на 90% всё ещё случается, но так редко, что игрок его прощает.
Почему честный рандом выглядит сломанным
Теперь про пространство выбора числе для рандома. Допустим, вам нужно раскидать по карте деревья, сундуки или стартовые точки врагов, и вы делаете это очевидным способом и просто берёте случайный x и случайный y.
Вот только результат почти всегда выглядит плохо, где-то три дерева слиплись в один куст, а рядом огромная пустая поляна, и если показать такую карту игроку, он скажет, что генератор кривой. Генератор при этом абсолютно и кристальное честный, это и есть настоящая равномерная случайность, просто она выглядит не так, как мы её себе представляем.
Эта история случилась со Spotify, когда пользователи начали жаловаться, что shuffle не случайный, потому что подряд играют песни одного исполнителя, поэтому инженерам пришлось сделать перемешивание менее случайным, чтобы оно ощущалось более случайным.
В пространстве эту задачу решают через Poisson disc sampling, и мы по-прежнему бросаем точки случайно, но выбрасываем те, которые оказались ближе заданного расстояния к уже поставленным. В самом наивном варианте это просто “бросай, пока не попадёшь в свободное место”, а быстрый вариант с сеткой и активным списком описал Роберт Бридсон в 2007 году.

В примере на 60 деревьев через честный рандом в среднем даёт двенадцать слипшихся пар, а Poisson ноль, и при этом второй лес почти все люди называют “более случайным” и если попросить человека самому расставить деревья “как попало”, он почти всегда нарисует что-то очень похожее на Poisson, никто не ставит деревья друг на друга, когда изображает случайность.
Storyteller и директор

Последний и самый большой трюк в этой группе касается не отдельных бросков, а того, когда вообще что-то происходит. Поэтому в любой игре с волнами врагов, рейдами, событиями или катастрофами есть вопрос расписания, и "честный" ответ на него выглядит как пуассоновский поток, каждую секунду с некоторой вероятностью что-то происходит, независимо от того, что случилось до этого.
void Tick() {
if (Random01() < wavesPerSecond)
SpawnWave();
}
Но проблема "честного" пуассоновского потока та же, что и у кубика без памяти, он не знает, что игрок только что отбил большую волну и у него осталось двадцать процентов здоровья, поэтому иногда две большие волны приходят подряд, а иногда игрок полторы минуты стоит и ничего не происходит. В одном матче игрок успевает пожаловаться и на то, что “игра нечестная”, и на то, что “скучно”.
Left 4 Dead решил это AI Director’ом, который следит за “интенсивностью” игроков, сколько урона они получили, сколько врагов вокруг них, насколько близко кто-то был к смерти, и раздаёт события по фазам: нарастание, пик, спад и передышка. RimWorld сделал из той же идеи целую фичу, где вы выбираете рассказчика, и Classic раздаёт события по нарастающей кривой с паузами, а Randy делает почти честный рандом, но игроки считают, что с Randy почти всегда больно.
void DirectorTick() {
float tension = aliveEnemies * kAlive + recentDamage * kDamage;
if (now < relaxUntil) return; // передышка
if (tension >= peakTension) { // пик, отпускаем
relaxUntil = now + relaxTime;
return;
}
if (now - lastWaveTime > maxCalmTime) { // слишком тихо
SpawnWave();
return;
}
float headroom = 1.0f - tension / peakTension;
if (Random01() < wavesPerSecond * boost * headroom)
SpawnWave();
}
Директор не делает игру проще в смысле количества врагов и раздаёт даже больше волн, чем честный поток, но при одинаковом слабом игроке-боте честный поток роняет базу в каждом седьмом забеге, а директор в одном, убирая моменты, когда к базе одновременно прорываются четыре и больше врагов. Директор не уменьшает нагрузку, но меняет её расписание, и игрок, который сыграл с директором, уверен, что всё было случайно. Опять же это все пришло из теории управления казино и покерных столов, когда увидели что люди, которым дали передышку после проигрыша в целом оставляют денег в полтора раза больше, чем те кто проиграл несколько игры подряд и давали указания крупье сдавать "слабым" игрокам более сильные карты.
Attack tokens и закон Ланчестера

Теперь представьте любой экшен от третьего лица, где игрок дерётся с толпой врагов ближнего боя. Если все сделать честно, то каждый враг подходит и бьёт, как только может, и игрок против восьми врагов не выживает пары секунд и швыряет свою мышку в монитор, жалуясь на баг балансировки, увы математика суровая тетка.
В 1916 году Фредерик Ланчестер показал, что в бою, где все стреляют во всех, сила армии растёт как квадрат её численности, и восемь врагов против одного героя это не в восемь, а в шестьдесят четыре раза сильнее. Восемь врагов наносят по 7 урона за цикл атаки в 1.4 секунды, это около 40 урона в секунду по герою со 100 здоровья, т.е. честная драка заканчивается за две с половиной секунды... математика сурова.
Поэтому все игры с толпами врагов используют attack tokens, или “кунг-фу круг”, когда существует ограниченное количество жетонов на атаку, и бить может только тот враг, у которого есть жетон, а остальные ходят по кругу вокруг, рычат, замахиваются и всячески изображают угрозу, но не атакуют. После удара враг отдаёт жетон, и его получает тот, кто ждал дольше всех, это делают Batman Arkham, Assassin’s Creed, и многие другие игры, где драка с толпой выглядит эффектно и при этом остаётся проходимой.

void AssignTokens() {
int holders = CountEnemiesWithToken();
auto waiting = EnemiesWithoutToken();
SortByWaitTimeDescending(waiting);
for (Enemy* e : waiting) {
if (holders >= maxTokens) break;
e->hasToken = true;
e->waitTime = 0;
++holders;
}
}
void EnemyAfterAttack(Enemy* e) {
e->hasToken = false; // отдаём очередь
e->ReturnToRing();
}
Враги при этом те же самые, урон тот же, скорость та же, изменилась только очередь, и с двумя жетонами потолок урона падает с сорока до десяти в секунду, но игрок при этом не видит очередь, он видит восемь врагов вокруг себя и чувствует, что дерётся с толпой и... героически побеждает.
Охранник, который честно не знает

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

Поэтому хорошее решение (не лучшее, просто хорошее) выглядит так, будто охранник рассуждает (это называется possibility map или occupancy map), когда мы храним на сетке вероятность того, что игрок находится в каждой клетке. В момент, когда игрок пропал из виду, вся вероятность сосредоточена в последней известной точке, а потом каждый тик она “растекается” на соседние проходимые клетки, потому что игрок мог сделать шаг, а все клетки, которые охранник сейчас видит, обнуляются, и если бы игрок был там, то охранник бы его увидел.

void DiffuseBelief(Grid& belief, const VisibleSet& seen) {
Grid next = Zero();
for (Cell c : belief.Cells()) {
auto ns = OpenNeighbours(c);
float share = belief[c] / (ns.size() + 1);
next[c] += share;
for (Cell n : ns) next[n] += share;
}
for (Cell c : seen) next[c] = 0.0f; // там тебя нет, иначе я бы тебя видел
Normalize(next);
belief = next;
}
После этого охранник идёт туда, где вероятность выше всего с поправкой на расстояние, и по пути вычёркивает всё, что видит, что выглядит как очень правдоподобный поиск, когда он заглядывает в коридоры, куда вы могли уйти, проверяет комнаты по очереди, и иногда угадывает, но угадывает честно.
Alien: Isolation пошёл третьим путём и сделал двухуровневую систему, где директор знает, где игрок, и время от времени подсказывает Чужому направление, а сам Чужой при этом ищет по своим сенсорам, т.е. враньё там есть, но дозировано и спрятано.
DDA, или противник, который поддаётся

Следующий трюк интереснее и основан опять на знании психологии, а люди оказываются очень предсказуемы, гораздо предсказуемее, чем им кажется, особенно когда пытаются вести себя случайно, чередуя, избегая повторов, но попадася на одних и тех же паттернах.
Классический способ это использовать описан у Иэна Миллингтона в книге про игровой AI и называется N-gram predictor, когда мы запоминаем последние N (обычно не больше 8) действий игрока и считаем, что он обычно делает после такой последовательности.
int PredictNext(const History& h) {
for (int len = order; len >= 0; --len) {
auto ctx = h.Last(len);
if (auto counts = table.Find(ctx); counts && counts->Total() > 0)
return ArgMax(*counts); // самый частый следующий ход
}
return RandomMove();
}
В примере с тремя дорожками, где стрелок пытается попасть в игрока, n-грамма третьего порядка очень быстро начинает угадывать ход, и стрелок “на полную” становится невыносимым. Дальше в игру вступает динамическая подстройка сложности, DDA, которая знает то же самое, что и предсказатель, но нарочно стреляет мимо, чтобы держать долю попаданий около заданного значения.
void AfterRound(bool playerWasHit) {
float error = RecentHitRate() - targetHitRate;
mercy = Clamp(mercy + error * gain, 0.0f, 1.0f);
}
int ChooseShot() {
int predicted = PredictNext(history);
if (Random01() < mercy)
return AnyLaneExcept(predicted); // знаю, куда пойдёшь, и мажу
return predicted;
}
Похожие системы прятались в очень многих играх, начиная от первых Resident Evil (незаметно меняет урон, точность и агрессию врагов) в зависимости от того, как вы играете или Crash Bandicoot после нескольких смертей подряд начинал давать поблажки на старте уровня, или Mario Kart раздаёт игрокам в хвосте гонки предметы получше, чем лидерам. Игроку никто об этом не говорит, потому что сразу заклеймят читерской игрой, да и сама идея “игра поддаётся” разрушает удовольствие от победы. Но победа над противником, который мажет, ощущается абсолютно так же, как победа над снайпером.
Спавн вне поля зрения
Этот трюк совсем простой, но без него ломается ощущение цельного мира, поэтому когда игре нужно создать нового врага и честная реализация выбирает случайную свободную точку на карте, то иногда эта точка оказывается прямо у игрока перед глазами. Тогда враг просто появляется из воздуха, и вместо живого мира игрок вдруг видит декорацию, которая собирается вокруг него.

Для этого игра считает полигон видимости игрока, т.е. ту область, куда он может смотреть с учётом стен, и ставит врагов только за его пределами, и это работает в обратную сторону, если врагов слишком много и одного нужно убрать, убирать надо того, кого игрок не видит, иначе получится неприятный эффект исчезновения. Left 4 Dead, кстати, делает так и специальные заражённые появляются там, куда выжившие не смотрят, поэтому кажется, что они вышли из-за угла.
Хитбоксы
Хитбокс это форма, которой персонаж сталкивается с миром, и она почти никогда не совпадает с реальностью, здесь самый наглядный пример, bullet hell вроде Touhou, где корабль игрока нарисован размером с хорошую монету, а хитбокс у него размером в несколько пикселей в центре, и игра даже показывает эту точку, когда вы замедляетесь. Если сделать хитбокс по картинке, то игрок будет постоянно умирать от пуль, которые прошли по краю крыла, и каждая такая смерть будет восприниматься как нечестная, потому что “оно же даже не задело”.

bool PlayerHit(const Bullet& b) {
return Distance(b.pos, player.pos) < coreRadius + b.radius; // а не spriteRadius
}
bool Collect(const Pickup& p) {
return Distance(p.pos, player.pos) < spriteRadius + magnetBonus;
}
Хитбокс для урона меньше картинки, а радиус подбора монет и бонусов больше, и игрок, который пролетел в пикселе от пули и собрал монету, до которой на самом деле не дотянулся, чувствует себя гением уклонения. Touhou даже превратил это в механику graze, где за пули, пролетевшие впритирку, начисляются очки.

Hit stop
Hit stop, он же hitlag, это заморозка игры на несколько кадров в момент удара. Например меч попадает по врагу, и на 50–100 миллисекунд и меч, и враг останавливаются, а потом анимация продолжается и если посмотреть по кадрам, это выглядит, что игра на секунду “зависла”, но в динамике этот эффект читается как сопротивление, будто меч во что-то упёрся, и удар кажется тяжелее. Файтинги используют это прием десятилетиями, а Масахиро Сакураи много рассказывал о том, как hitlag в сделали отдельной механикой и он растёт вместе с силой удара.

void OnHit(Attacker& a, Target& t, float damage) {
t.hp -= damage;
float freeze = BaseHitStop + damage * HitStopPerDamage;
a.localTimeFrozen = freeze; // останавливаем только участников удара
t.localTimeFrozen = freeze;
camera.Shake(0.15f);
t.Flash(Color::White, 0.06f);
}
void Update(Entity& e, float dt) {
if (e.localTimeFrozen > 0) { e.localTimeFrozen -= dt; return; }
e.Animate(dt);
}
Сам по себе hit stop не меняет ни урона, ни длительности боя в заметной степени и если удар длится восемнадцать кадров, то заморозка на четыре-пять кадров почти ничего не добавляет к времени. А вот если вы поставите рядом два одинаковых удара с заморозкой и без, то большинство людей уверенно скажут, что первый сильнее, а дальше туда обычно добавляют тряску камеры, белую вспышку, отброс и частицы, и всё это вместе в индустрии называют “juice”, хотя на урон это вообще никак не влияет.
Ноги и IK

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

void SolveLeg(Vec2 hip, Vec2 target, float l1, float l2, Vec2& knee) {
float d = min(Length(target - hip), l1 + l2 - eps);
float base = Angle(target - hip);
float off = acos((l1 * l1 + d * d - l2 * l2) / (2.0f * l1 * d));
knee = hip + FromAngle(base + off * bendSign) * l1; // колено вперёд
}
void UpdateFeet() {
for (Foot& f : feet) {
if (f.planted && DistanceBehindHip(f) > stride * 0.5f && !OtherFootStepping(f))
f.StepTo(GroundAt(hip.x + dir * stride * 0.5f)); // шагаем, только когда надо
}
hip.y = LowestHipThatReaches(feet, idleHipHeight);
}
Отдельный момент, ступня, которая стоит на земле, не должна скользить, и пока персонажу не нужно сделать следующий шаг (именно поэтому ноги в хорошей реализации ведут себя как ноги, а не как анимация) она остается привязаная к некоторой точке. Капсула при этом по-прежнему едет вперед, просто мы соврали игроку где сейчас реально должна находиться нога.
Где проходит граница
Если собрать все эти трюки вместе, становится видно, что у них одно общее свойство, ни один из них не делает игру нечестной по отношению к игроку, наоборот, почти все они делают её "естественнее" с точки зрения восприятия игрока. Но граница проходит там, где враньё начинает работать против игрока или когда игрок его замечает и всезнающий охранник палится сразу, ломает иллюзию мира и обесценивает победу. Хорошее враньё в играх всегда невидимо, и оно всегда на стороне человека, мы привыкли думать, что игра это система правил, а игрок их исполнитель, но если все сделать честно и правильно, то игрок будет проигрывать и жаловаться что игра была нечестной. Как говорил незабвенный мастер Габен, хотите пирогов с котятками... ищите ингридиенты.

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