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

Я являюсь выпускником МИП-15 и в свободное время разрабатываю игру для чатов в Telegram с элементами RPG - Krezar Tavern, исходный код которой можно посмотреть на гитхабе.

В чём проблема?

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

Мы можем написать на любом языке программировании примерно следующее: random.nextInt(1, 100) <= 80. Всё работает, можно в прод. Но спустя какое-то время игроки начинают жаловаться на сломанный рандом. Почему?

Сделав выгрузку с продовой базы, видим, что у 51% игроков успешных квестов меньше 79%, а у 34% – выше чем 81%.

Прежде чем начать винить ГСПЧ в нашем языке программирования, заметим, что суммарная вероятность близка к целевой. Здесь присутствует когнитивное искажение, ведь мы хотим, чтобы вероятность в 80% была у каждого игрока! А в коде мы написали глобальный вызов генератора случайных чисел.

Настраиваем вероятность

Разберём на практике несколько способов, с помощью которых можно реализовать рандом в нашей игре.

Каждую реализацию мы будем прогонять через бенчмарк: Отправим 1000 игроков в 5000 событий с целевым шансом 80% успеха и посмотрим, как будет меняться вероятность для каждого из них со временем.

Реализация бенчмарка на Java
public class Benchmark {
    private static final Map<Integer, PlayerStats> playersStats = new HashMap<>();
    // здесь будет менять реализация randomProvider по ходу статьи
    private static final RandomProvider randomProvider; 


    public static void main(String... args) {
        final var playerCount = 1_000;
        final var eventCount = 5_000;

        for (int i = 1; i <= eventCount; ++i) {
            for (int playerId = 0; playerId < playerCount; ++playerId) {
                final var success = randomProvider.next(playerId, playersStats) ? 1 : 0;
                final var currentValue = playersStats.get(playerId);
                if (currentValue != null) {
                    currentValue.successCount += success;
                    currentValue.successRates.add((double) currentValue.successCount / i);
                } else {
                    final var stats = new PlayerStats();
                    stats.successCount = success;
                    stats.successRates = new ArrayList<>();
                    stats.successRates.add((double) success);
                    playersStats.put(playerId, stats);
                }
            }
        }
	
        // для записи csv используется com.opencsv:opencsv:5.9
        final var csvFilePath = "output.csv";
        try (final var writer = new CSVWriter(new FileWriter(csvFilePath))) {
            final String[] header = {"id", "success_rates"};
            writer.writeNext(header);
            for (final var entry : playersStats.entrySet()) {
                final String[] record = {entry.getKey().toString(), entry.getValue().successRates.toString()};
                writer.writeNext(record);
            }
        } catch (IOException _) {
        }
    }
}

public class PlayerStats {
    public int successCount;
    public ArrayList<Double> successRates;
}

public interface RandomProvider {
   boolean next(int playerId, Map<Integer, PlayerStats> playersStats);
}

public class RandomUtils {
   private static final RandomGenerator random = RandomGenerator.getDefault();


   public static int getInInterval(int start, int end) {
       if (start >= end) {
           return start;
       }
       return random.nextInt(start, end + 1);
   }


   public static boolean processChance(int percent) {
       if (percent >= 100) {
           return true;
       }
       final var result = getInInterval(1, 100);
       return result <= percent;
   }
}

Честный рандом

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

Реализация честного рандома на Java
public class HonorRandom implements RandomProvider {
    private static final int targetChance = 80;

    @Override
    public boolean next(int playerId, Map<Integer, PlayerStats> playersStats) {
        return RandomUtils.processChance(targetChance);
    }
}

Интервал между точками составляет 50 событий
Интервал между точками составляет 50 событий

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

Плюсы честного подхода:

  • Не надо хранить данные о предыдущих исходах

  • Простая реализация

  • Высокая непредсказуемость для игрока

Минусы реализации:

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

  • Долгая сходимость к целевой вероятности

Честная вероятность отлично подходит, если событий в нашей системе много и игрок не тратит много ресурсов на их срабатывание. Например, криты или увороты в боёвке.

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

Пул событий

Что означает вероятность в 80%? Как правило, то что мы хотим 80 успешных событий из 100. Если сократить, то 8 из 10.

Поэтому мы можем сделать пул из 10 элементов, в котором случайном образом будет расположено 8 успешных и 2 не успешных квеста.

Реализация пула событий на Java
public class RandomPool implements RandomProvider {
    private static final int poolSize = 10;
    private static final int successCount = 8;
    private final Map<Integer, BooleanPool> playersPools = new HashMap<>();

    @Override
    public boolean next(int playerId, Map<Integer, PlayerStats> playersStats) {
        var pool = playersPools.get(playerId);
        if (pool == null || pool.isEmpty()) {
            pool = BooleanPool.generate(successCount, poolSize);
            playersPools.put(playerId, pool);
        }
        return pool.next();
    }

    record BooleanPool(
        Queue<Boolean> queue
    ) {
        public static BooleanPool generate(int successCount, int poolSize) {
            final var list = new ArrayList<Boolean>(poolSize);
            for (int i = 0; i < successCount; i++) {
                list.add(true);
            }
            for (int i = successCount; i < poolSize; i++) {
                list.add(false);
            }
            Collections.shuffle(list);
            return new BooleanPool(new LinkedList<>(list));
        }

        public boolean next() {
            return queue.poll();
        }

        public boolean isEmpty() {
            return queue.isEmpty();
        }
    }
}

Интервал между точками составляет 5 событий
Интервал между точками составляет 5 событий

Здесь чётко видно, что каждые 10 событий, все линии пересекают 80%. И с ростом количества событий колебания становятся всё меньше, и намного быстрее, чем в честной вероятности.

Естественно, у пула есть недостатки:

  • Его надо где-то хранить на нашем сервере.

  • При фиксированном размере, игроки могут достаточно легко отследить его границы.

Плюсы данного подхода:

  • Легко контролировать количество событий

  • Спокойно расширяется на большее количество вариаций исходов

Как можно улучшить пул:

  • Сделать разброс по размеру пула, чтобы игрокам было сложнее отследить, когда он заканчивается и какие события в нём остались. В одном пуле может быть 8 успешных и 2 провальных. В другом – 12 на 3.

  • Настройки на расположение: не более N одинаковых событий подряд или события X и Y не должны стоять рядом.

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

Динамическая вероятность

Подход достаточно прост в своей идее. У нас есть целевая вероятность A и текущий процент успеха игрока B. Если B > A, значит итоговая вероятность C будет меньше A. Если B < A, значит C > A. A = B => C = A.

В общем виде формулу можно описать как:

playerRate = targetRate + F(targetRate, currentRate)

Функция F возвращает отклонение от целевой вероятности, основываясь на вышеописанных правилах. Рассмотрим линейную реализацию:

F = (targetRate - currentRate) * deviationCoefficient

Несложно заметить, что если deviationCoefficient = 0, то мы вернёмся назад к честному рандому. А чем больше deviationCoefficient, тем быстрее будет сходиться наш график.

Реализация динамической вероятности на Java
public class DynamicRandom implements RandomProvider {
    // Корректировать deviationCoefficient можно в зависимости от потребностей
    private static final int deviationCoefficient = 50;
    private final double targetRate = 0.8;

    @Override
    public boolean next(int playerId, Map<Integer, PlayerStats> playersStats) {
        final var stats = playersStats.get(playerId);
        final double currentRate;
        if (stats == null) {
            currentRate = targetRate;
        } else {
            currentRate = stats.successRates.getLast();
        }
        final var playerChance = (int) Math.round(
            (targetRate + (targetRate - currentRate) * deviationCoefficient) * 100
        );
        return RandomUtils.processChance(playerChance);
    }
}

График при deviationCoefficient = 2
Интервал между точками составляет 50 событий
Интервал между точками составляет 50 событий

deviationCoefficient = 10. Интервал между точками составляет 50 событий
deviationCoefficient = 10. Интервал между точками составляет 50 событий

Плюсы данного подхода:

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

Но есть и минусы:

  • Сложно масштабировать, если исходов больше двух.

  • Нужно где-то сохранять текущий процент для каждого игрока и модифицировать его после каждого события.

  • Сложно встроить дополнительные правила генерации.

  • При слишком большом deviationCoefficient можно попасть на простое чередование событий

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

Гарант

Гарант означает, что ожидаемый исход 100% наступит через определённое количество событий. Данная система популярна в играх с механикой гачи, например Genshin Impact. Там каждые 90 молитв падает предмет с качеством 5 звёзд.

 График с сайта paimon.moe, который собирает статистику с игроков
 График с сайта paimon.moe, который собирает статистику с игроков

Видим, что до 74-ой молитвы вероятность постоянна и примерна равна 0.8%, а дальше +- линейно растёт до 100% на 90-ой попытке.

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

Параметры которые понадобятся для гаранта:

  • Максимальное количество событий по умолчанию до нужного (A).

  • Вероятность целевого события по умолчанию (C).

  • Количество событий, с которого вероятность начинает расти (B).

  • Формула по которой растёт вероятность между B и A (линейно, степенная функция или что-то другое).

Играясь с параметрами, можно смотреть на графики и выбрать подходящие.

Реализация на Java
public class GuaranteedRandom implements RandomProvider {
    private final HashMap<Integer, Integer> playersSuccessRows = new HashMap<>();
    private final int expectFailEvent = 6;
    private final int defaultChanceCount = 3;
    private final int defaultChance = 5;

    @Override
    public boolean next(int playerId, Map<Integer, PlayerStats> playersStats) {
        int successRow = playersSuccessRows.getOrDefault(playerId, 0);
        final int currentChance;
        if (successRow <= defaultChanceCount) {
            currentChance = defaultChance;
        } else if (successRow == expectFailEvent - 1){
            currentChance = 100;
        } else {
            int length = expectFailEvent - defaultChanceCount;
            int passed = successRow - defaultChanceCount;
            currentChance = 100 / length * passed;
        }
        final var isFailed = RandomUtils.processChance(currentChance);
        if (!isFailed) {
            playersSuccessRows.put(playerId, successRow + 1);
        } else {
            playersSuccessRows.put(playerId, 0);
        }
        return !isFailed;
    }
}

Интервал между точками составляет 50 событий. A = 6; C = 5%; B = 3; линейный рост
Интервал между точками составляет 50 событий. A = 6; C = 5%; B = 3; линейный рост

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

Плюсы гаранта:

  • Прозрачность для игрока

  • Относительно просто масштабировать на большее количество исходов

Минусы решения:

  • Сложная настройка ожидаемой вероятности

  • Надо сохранять данные, о количестве событий по умолчанию подряд.

Данная реализация достаточно хорошо известна игрокам, когда речь заходит о всяких гача-механиках. Если вы хотите дать гарантию игроку (или вас заставляет закон), то это ваш выбор.

Заключение

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

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

  • Смотрим на вероятность в динамике, а не на конечном количестве событий.

  • Всегда симулируем вероятности для нескольких игроков, а не одного.

  • Если вы геймдизайнер, приложите графики к задаче, с наглядным примером, как выглядит ваш рандом в динамике.

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

Не стесняйтесь экспериментировать с формулами: подбирать числа, комбинировать, придумывать свои. Главное, чтобы ваши игроки остались довольны.

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