Избавляем игрока от раздражения: правильное использование случайных чисел

https://gamedevelopment.tutsplus.com/articles/solving-player-frustration-techniques-for-random-number-generation--cms-30428
  • Перевод
image

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

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

Генератор случайных чисел и его применение


Случайные числа встречаются нам повсюду и используются для добавления вариативности в ПО. В общем случае RNG обычно применяются для моделирования хаотических событий, демонстрации непостоянства или являются искусственным ограничителем.

Скорее всего, вы ежедневно взаимодействуете со случайными числами или результатами их действий. Они используются в научных опытах, видеоиграх, анимациях, искусстве и практически в каждом приложении компьютера. Например, RNG скорее всего реализован в простых анимациях в вашем телефоне.

Теперь, когда мы поговорили немного о RNG, давайте взглянем на их реализацию и узнаем, как применять их для улучшения игр.

Стандартный генератор случайных чисел


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

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

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

Взвешенные случайные числа и слоты редкости


Этот тип RNG является основой для любой RPG с системой редкости предметов. В частности, он применяется, когда вам нужен рандомизированный результат, но некоторые значения должны выпадать с меньшей частотой, чем остальные. При изучении вероятностей в пример часто приводят мешок с шариками. При взвешенном RNG в мешке может быть три синих шарика и один красный. Так как нам нужен всего один шарик, мы получим или красный, или синий, но с большей вероятностью он будет синим.

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

Виды и применения


Группировка одинаковых предметов


Во многих книгах по информатике такой способ называется «мешком». Имя говорит само за себя — классы или объекты используются для создания визуального представления мешка в буквальном смысле.

По сути, это работает так: есть контейнер, в который можно поместить объекты, функция для помещения объекта в «мешок» и функция для случайного выбора предмета из «мешка». Возвращаясь к нашему примеру с шариками, можно сказать, что наш мешок будет содержать синий, синий, синий и красный шарики.

С помощью этого способа рандомизации мы можем приблизительно задавать частоту возникновения результатов, чтобы усреднить игровой процесс для каждого игрока. Если мы упростим результаты до шкалы от «Очень плохо» до «Очень хорошо», то получим гораздо более приятную систему по сравнению с той, когда игрок может получить ненужную последовательность нежелательных результатов (например, результатов «Очень плохо» 20 раз подряд).

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

Вот краткий пример того, как может выглядеть псевдокод класса мешка:

Class Bag {
    //Создаём массив для всех элементов, находящихся в мешке
	Array itemsInBag;

	//Заполняем мешок предметами при его создании
	Constructor (Array startingItems) {
		itemsInBag = startingItems;
	}

	//Добавляем предмет в мешок, передавая объект (а затем просто записываем его в массив)
	Function addItem (Object item) {
		itemsInBag.push(item);
	}

	//Для возврата случайного предмета используем встроенную функцию random, возвращая предмет из массива
	Function getRandomItem () {
		return(itemsInBag[random(0,itemsInBag.length-1)]);
    }
}

Реализация слотов редкости


Слоты редкости — это способ стандартизации для задания частоты выпадания объекта (обычно используемый для упрощения процесса создания дизайна игры и вознаграждений игрока).

Вместо задания частоты каждого отдельного предмета в игре мы создаём соответствующую ему редкость — например, редкость «Обычный» может представлять вероятность определённого результата 20 к X, а уровень редкости «Редкий» — вероятность 1 к X.

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

Кроме того, разделение редкости на слоты полезно для изменения восприятия игрока. Оно позволяет быстро и без необходимости возиться с числами понять, как часто должно происходить событие, чтобы игрок не терял интереса.

Вот простой пример того, как мы можем добавить в наш мешок слоты редкости:

Class Bag {
    //Создаём массив для всех элементов, находящихся в мешке
	Array itemsInBag;

	//Добавляем предмет в мешок, передавая объект
	Function addItem (Object item) {
		
		//Отслеживаем циклы относительно разделения редкости
		Int timesToAdd;
		
		//Проверяем переменную редкости предмета
        //(но сначала создаём эту переменную в классе предмета,
        //предпочтительно перечислимого типа)
		Switch(item.rarity) {
			Case 'common':
				timesToAdd = 5;
			Case 'uncommon':
				timesToAdd = 3;
			Case 'rare':
				timesToAdd = 1;
		}
        
        //Добавляем экземпляры предмета в мешок с учётом его редкости
		While (timesToAdd >0)
		{
            itemsInBag.push(item);
            timesToAdd--;
        }
	}
}

Случайные числа с переменной частотой


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

Зачем нам это может понадобиться? Возьмём, например, игры с собиранием. Если у нас есть десять возможных результатов для получаемого предмета, когда девять являются «обычными», а один — «редким», то тогда вероятности очень просты: 90% времени игрок будет получать обычный предмет, а 10% времени — редкий. Проблема возникает тогда, когда мы учитываем несколько вытягиваний из мешка.

Давайте посмотрим на наши шансы получения серии обычных результатов:

  • При первом вытягивании вероятность получения обычного предмета равна 90%.
  • При двух вытягиваниях вероятность получения обоих обычных предметов равна 81%.
  • При 10 вытягиваниях по-прежнему существует вероятность 35% всех обычных предметов.
  • При 20 вытягиваниях всё равно есть вероятность в 12%.

То есть хотя изначальное соотношение 9:1 казалось нам идеальным, на самом деле оно соответствует только средним результатам, то есть 1 из 10 игроков потратит на получение редкого предмета вдвое больше желаемого. Более того, 4% игроков потратят на получение редкого предмета в три раза больше времени, а 1,5% неудачников — в четыре раза больше.

Как эту проблему решают переменные частоты


Решение заключается в реализации в наших объектах интервала случайности. Для этого мы задаём максимальную и минимальную редкость каждого объекта (или слоты редкости, если вы хотите соединить этот способ с предыдущим примером). Например, давайте дадим нашему обычному предмету минимальное значение редкости 1, а максимальное — 9. Редкий предмет будет иметь минимальное и максимальное значение 1.

Теперь по показанному выше сценарию у нас будет десять предметов, девять из которых являются экземплярами обычного, а один — редким. При первом вытягивании есть вероятность 90% получения обычного предмета. При переменных частотах после вытягивания обычного предмета мы снижаем его значение редкости на 1.

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

Таким образом, вместо вероятности 35% вытягивания 10 обычных предметов подряд у нас останется вероятность всего в 5%. Вероятность граничных результатов, таких как вытаскивание 20 обычных предметов подряд, снижается до 0,5%, а дальше становится и того меньше. Это создаёт постоянные результаты для игроков, и защищает нас от граничных случаев, в которых игрок постоянно вытягивает плохой результат.

Создание класса переменных частот


Самой простой реализацией переменной частоты будет извлечение предмета из мешка, а не просто его возвращение:

Class Bag {
    //Создаём массив для всех элементов, находящихся в мешке
	Array itemsInBag;

	//Заполняем мешок предметами при его создании
	Constructor (Array startingItems) {
		itemsInBag = startingItems;
	}

	//Добавляем предмет в мешок, передавая объект (а затем просто записываем его в массив)
	Function addItem (Object item) {
		itemsInBag.push(item);
	}

	Function getRandomItem () {
        //pick a random item from the bag
		Var currentItem = itemsInBag[random(0,itemsInBag.length-1)];
        
        //Снижаем количество экземпляров этого предмета, если он выше минимума
		If (instancesOf (currentItem, itemsInBag) > currentItem.minimumRarity) {
			itemsInBag.remove(currentItem);
		}
        
		return(currentItem);
}
}

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

Развитие идеи


Мы описали основную идею переменных частот, но в собственных реализациях нам нужно рассмотреть ещё довольно много аспектов:

  • Удаление предметов из мешка позволяет добиться постоянных результатов, но со временем возвращает нас к проблемам стандартной рандомизации. Как мы можем изменить функции таким образом, чтобы и увеличение, и уменьшение предметов позволяли этого избежать?
  • Что происходит, когда мы имеем дело с тысячами или миллионами предметов? В этом случае решением может быть использование мешка, заполненного мешками. Например, можно создать мешок для каждой редкости (все обычные предметы в одном мешке, редкие в другом) и поместить каждый из них в слоты внутри большого мешка. Это обеспечит широкие возможности для манипуляций.

Менее скучные случайные числа


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

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

Подводим итог


Генерация случайных чисел — один из столпов хорошего геймдизайна. Для улучшения игрового процесса тщательно проверяйте свою статистику и реализуйте наиболее подходящие виды генерирования.
Поделиться публикацией
Комментарии 18
    0
    Спасибо!
    А есть идеи по оптимизации взвешенного рандома «в бою»?
    «ВБР» многих достал. Как в такой ситуации быть разработчику?
      0
      В warcraft'е (который стратегия) используются кости, например — habr.com/post/335840
        0
        ВБР работает, по сути, по тому же принципу.
      0
      Непонятно зачем изобретать всякие «мешки» (с тремя синими и одним красным шарами), если можно сделать просто так:
      if (random(1, 100) <= 25) {
          getRareItem();
      } else {
          getNormalItem();
      }
      И уже этот подход можно обернуть в красивые функции-генераторы и т.п.

      А насчёт последнего подхода, «с подкладыванием» хорошего результата, — довольно сомнительное решение.
        0
        а где гарантия что на 100 бросков random вернет результат в пределах 1-25? Самый простейший способ это создать массив с числами в соотношении вероятности и перемешать чем угодно.
          0

          Зря минусуете камрада, я вот лично столкнулся с очень странным поведением C#-го стандартного Random.Range(): из 100 сферическо-вакуумных бросков костей как-то подозрительно часто одни и те же значения выпадают… условно 33 шестерки и 25 единиц...


          За подсказку с изменением веса спасибо, не догадался, это всякие [ММО]РПГ большими командами пилят, а в инди все грабли на себя собирать приходится)


          Кстати, в Бельгии намедни лутбоксы законодательно запретили, особенно в детских играх…

          0
          Ваш код реализует как раз-таки стандартный генератор обычных и редких вещей. При шансе выпадения редкой в среднем в 1 из 4 случаев невезучие люди получат 0 из 4, а везучие — 2, 3 или даже 4. Это-то всем геймерам и не нравится. А если есть мешок с тремя обычными и одной редкой, из которого эти вещи достают, то самое большое различие — невезучие люди вытащат редкую вещь последней, а везучие — первой. Намного лучше, правда же?

          Остается вопрос лишь о размере этого мешка. Можно сделать его минимальным, 4 вещи. Тогда игра станет слишком предсказуемой, ведь вы всегда знаете, что редкая вам выпадет не позднее чем через 6 вещей (1ый мешок — редкая, 3 обычных, 2ой мешок — 3 обычных, редкая). Можно сделать чуть побольше. 8 вещей, 6 обычных и 2 редкие. Чуть менее предсказуемо. Таким образом, вы берете власть над рандомизацией в свои руки. Больше размер мешка — больше рандомность. И конечно же, не стоит делать размер мешка в 4 миллиона вещей. Невезучим игрокам их миллион редких вещей будет доставаться в самом конце мешка. Когда они уже бросят эту невезучую для них игру.
            0
            Если допустить что есть классы вещей(от более частых к более редким):
            серые, зеленые, синие, фиолетовые, красные.
            Каждый класс имеет также свои подклассы — различный мусор, зелья, шмотки.
            Итого имеем 15 категорий в которых разные шансы выпадения, причем они могут иметь привязку к определенным модам, событиям, учитывать уровень игрока, а также количество уже существующих таких шмоток в игре(что бы не приводить к утрате ценности вещей).

            А еще понадобится подкрутить рейты(может так получиться что рейты завышены или занижены)

            Из за этого ваш подход становится неудобным, и вы прийдете к «мешкам» которые по сути будут тем же самым что вы написали, с тем отличием что условия могут меняться на лету, тем более проще видь в БД писать вещи и вероятности их выпадения, нежели хардкодить, или даже выносить в конфиг.
            0
            Не знаю как в других играх, но ВБР в Мире Танков, Кораблей только в самом начале был веселым и задорным, невидимым. Сейчас в это сложно играть. Надеюсь другие игры не пойдут по этому пути.
              –1
              Вначале, это когда краевые значения флипались к краю и огромное количество снарядов летело в круг? Ничего веселого там не было. Просто вам надоела игра.
                0
                Немного вне темы. Турбосливы и турбопобеды появились далеко не сразу и сейчас их очень много. Понятно, что количество игроков выросло в разы по сравнению в 2011 годом, но балансило явно иначе.
                  –1
                  Это вы о том балансе, когда в одной команде максимум 8-й уровень, а в другой даже пару десяток? Да, никаких турбосливов. Ещё раз — вам просто надоела игра, по рандому там все стало намного лучше
              +1

              Неясно откуда в способе "Слоты редкости" взялись цифры 1, 3, 5 и что именно они означают.
              В такой контейнер можно сунуть 10 предметов средней редкости, 1 обычной и 5 редких. Вероятность выпадения любого обычного предмета при этом будет 15 / (15+103+51) = 0.125 и любого "редкого" тоже 0.125.
              По-хорошему в этом способе нужно учитывать количество предметов каждой редкости, чтобы не было подобных аномалий.

                0

                Формула немного исказилась: 1*5 / (1*5+10*3+5*1)

                  0
                  11% / 33% / 55% вероятность достать редкий / необычный / обычный предметы и эта вероятность контролируется количеством экземпляров конкретной редкости. Вы же добавили к этой системе «второй раунд» оценки и получилось черте знает что. Для ваших данных коэффициенты 1,3,5 надо забыть, а вместо них прописать 5,1,10, тогда вероятности распределятся как 31,25 % / 6,25 % / 62,5%.
                    0
                    Вы же добавили к этой системе «второй раунд» оценки и получилось черте знает что.

                    Какой еще раунд? Я работаю исключительно с контрактом класса. Метод addItem принимает на вход любой предмет. Значит им можно воспользоваться как то так


                    Bag bag = new Bag();
                    bag.addItem({name='item1', rarity='uncommon'});
                    bag.addItem({name='item2', rarity='uncommon'});
                    bag.addItem({name='item3', rarity='uncommon'});
                    bag.addItem({name='item4', rarity='uncommon'});
                    bag.addItem({name='item5', rarity='uncommon'});
                    
                    bag.addItem({name='item6', rarity='uncommon'});
                    bag.addItem({name='item7', rarity='uncommon'});
                    bag.addItem({name='item8', rarity='uncommon'});
                    bag.addItem({name='item9', rarity='uncommon'});
                    bag.addItem({name='item10', rarity='uncommon'});
                    
                    bag.addItem({name='item11', rarity='common'});
                    
                    bag.addItem({name='item12', rarity='rare'});
                    bag.addItem({name='item13', rarity='rare'});
                    bag.addItem({name='item14', rarity='rare'});
                    bag.addItem({name='item15', rarity='rare'});
                    bag.addItem({name='item16', rarity='rare'});

                    При этом получится описанная мной ситуация с аномалиями в распределении.
                    Такая же ситуация, только чуть менее явная, может получиться когда предметы добавляются в пул мешка каким-нибудь инструментом. А потом игроки ловят интересные проблемы, когда обычный предмет встречается сильно реже, чем "редкий".


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

                      0
                      Ок, я понял. Там действительно отсутствует балансировка вероятностей. Либо метод addItem предполагался приватным. В любом случае это лишь пример, интерфейс класса явно не доделан.
                  +2
                  Вот так казуальщина убивает хардкор :D

                  Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                  Самое читаемое