Где ещё можно ввести игрока в заблуждение?
Это вторая часть рассказа о приёмах, хитростях и любопытных механиках, которые я вспомнил во время работы над статьёй о coyote time. Там этот приём мог показаться небольшой и почти безобидной уловкой, которая существует только у края платформы. На деле же он лишь самая заметная часть обширной кухни разработки игр. Случайные события, поведение противников, хитбоксы и даже положение ног персонажа нередко требуют подобных допущений: не потому, что разработчики хотят кого-то обмануть, а потому, что абсолютно честная симуляция зачастую ощущается хуже тщательно настроенной. Игрок не видит формул, но видит их последствия. И если они кажутся ему несправедливыми, ссылки на корректный генератор случайных чисел вряд ли убедят его, что игра не жульничает.
Особенно забавно, что жалобы часто вызывают именно честные системы. На форумах практически любой игры с добычей можно найти темы вроде: «Шанс 10% — ложь, я убил босса сорок раз и ничего не получил». Расстроенный игрок переносит свои впечатления в отзыв, хотя его выводы ошибочны. При честном шансе 10% сорок неудач подряд выпадают примерно одному игроку из семидесяти. Если аудитория игры насчитывает сто тысяч человек, таких невезучих окажется немало — и каждому из них будет от этого не легче.
Сид Мейер рассказывал, что игроки Civilization отказывались верить честным вероятностям в бою. Поражение при соотношении шансов 3 к 1 в свою пользу воспринималось как обман: преимущество казалось настолько большим, что проигрыш выглядел невозможным. Увидев числа в игре, люди нередко трактуют их в свою пользу. В итоге шансы в Civilization пришлось скорректировать так, чтобы они соответствовали не столько математике, сколько ожиданиям игроков.
Как игры обманывают нас со случайностью
Люди плохо представляют себе, как выглядит случайность. Это давно известная психологическая особенность, с которой разработчикам приходится считаться. Мы ожидаем увидеть последовательность, равномерно перемешанную и лишённую длинных серий, скоплений и повторов. Однако настоящая случайность спокойно порождает всё перечисленное: ей безразлично, что происходило раньше. Поэтому игровые генераторы, которые должны восприниматься как справедливые, нередко специально настраивают в пользу игрока. В конце концов, людям нравится побеждать.

Начнём с распространённого примера: у предмета указан шанс выпадения 10%. Прямая реализация могла бы выглядеть так:
bool RollDrop(float chance) {
return Random01() < chance;
}
Такой бросок ничего не помнит. Даже если игрок потерпел неудачу тридцать раз подряд, на тридцать первой попытке его шанс по-прежнему составляет 10%. Вероятность серии из N промахов экспоненциально уменьшается, но никогда не становится нулевой. На ста тысячах бросков самая длинная полоса неудач может достигать 70–80 попыток. Для игрока, которому она достанется, игра, скорее всего, навсегда останется «сломанной».
Pity timer решает проблему, учитывая предыдущие неудачи. После определённого порога вероятность начинает расти, а затем выпадение становится гарантированным. В гача-играх эта идея превратилась в целую систему: soft pity заметно повышает шанс после нескольких десятков попыток, а hard pity гарантирует предмет, например, на девяностой попытке, если раньше он так и не выпал. Само число 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 timer фактически сообщает игроку не всю правду: выигрыш происходит чаще, чем следует из базового шанса в описании предмета. Например, если указаны 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 будет выпадать одинаково часто. Таким образом, примерно каждый двенадцатый удар нанесёт всего единицу урона. Игрок запомнит такую неудачу и может посчитать её несправедливой. Если вместо этого бросать 2d6, диапазон получится почти тем же, среднее вырастет до 7, а распределение станет треугольным: единица не выпадет никогда, а двойка появится в одном случае из тридцати шести. Спросите любителей D&D, какой вариант честнее: 2d6 или 1d12.
Впрочем, здесь ещё нет обмана: мы просто выбрали другую формулу и объяснили её игроку. Более любопытный пример — Fire Emblem. Начиная с Binding Blade игра может показывать, скажем, 70% вероятности попадания, но внутри выбирает не одно случайное число от 0 до 99, а два и вычисляет их среднее:

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

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

Следующий приём управляет не отдельными бросками, а временем наступления событий. В играх с волнами противников, набегами и катастрофами нужно решить, когда именно они происходят. Простой «честный» вариант напоминает пуассоновский поток: каждую секунду событие может произойти с некоторой вероятностью, независимо от прошлого.
void Tick() {
if (Random01() < wavesPerSecond)
SpawnWave();
}
У такого потока та же проблема, что и у кубика без памяти. Он не знает, что игрок только что отбил мощную волну и остался с двадцатью процентами здоровья. Поэтому две крупные атаки могут прийти одна за другой, а затем полторы минуты не будет происходить вообще ничего. За один матч игрок успеет пожаловаться и на несправедливость, и на скуку.
В Left 4 Dead эту задачу решает AI Director. Он следит за интенсивностью происходящего: учитывает полученный урон, число врагов поблизости и близость игроков к смерти, а затем распределяет события по фазам нарастания, пика, спада и передышки. RimWorld развивает ту же идею в системе рассказчиков. Classic выдаёт события по нарастающей кривой с паузами, а 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, или противник, который поддаётся

Следующий приём тоже опирается на психологию. Люди предсказуемее, чем им кажется, особенно когда пытаются действовать случайно: они чередуют действия, избегают повторов и незаметно для себя возвращаются к одним и тем же шаблонам.
Классический способ использовать эту особенность описан в книге Иэна Миллингтона об игровом ИИ. N-gram predictor запоминает последние N действий игрока, обычно не больше восьми, и определяет, какой ход чаще всего следует за такой последовательностью.
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);
}
Есть и ещё одна важная деталь: опорная ступня не должна скользить. Пока не пришло время следующего шага, её удерживают в одной точке. Благодаря этому ноги выглядят естественно, а не просто воспроизводят зацикленную анимацию. Физическая капсула продолжает двигаться вперёд, но видимое положение ноги корректируется.
Где проходит граница
У всех этих приёмов есть общая черта: они не обязательно делают игру несправедливой по отношению к игроку. Напротив, многие помогают ей выглядеть и ощущаться естественнее. Граница проходит там, где обман начинает работать против человека или становится заметным. Всезнающий охранник быстро выдаёт себя, разрушает иллюзию мира и обесценивает успех. Хорошая игровая уловка остаётся незаметной и служит впечатлению игрока. Мы привыкли считать игру системой правил, которой человек подчиняется, но без продуманных допущений даже математически корректная система может восприниматься как несправедливая. Как говорил незабвенный Габен: хотите пирогов с котятками — ищите ингредиенты.
Фэнтезийный аналог Papers, Please вышел на смартфонах
Obsidian представила первое дополнение для The Outer Worlds 2, которое выйдет уже скоро
Mass Effect ещё вернётся, а GTA VI будет работать при 30 fps даже на PS5 Pro — главное за 10 ноября
Двухмерную метроидванию Nine Sols в духе Sekiro перенесли на Android
На Prime Video стартовал третий сезон «Колец власти»
Издатель отреагировал на скандал после удаления You are Empty из Steam за нарушение авторских прав
Лучший сезон или худший? Вышли первые рецензии на третий сезон сериала «Властелин колец: Кольца власти»
Пятикратно пережёванное аниме и критика Хаяо Миядзаки в адрес нового поколения аниматоров