Форум программистов, компьютерный форум, киберфорум
GameUnited
Войти
Регистрация
Восстановить пароль

Адаптивная случайность в Unity: динамические вероятности для улучшения игрового дизайна

Запись от GameUnited размещена 02.11.2025 в 21:44. Обновил(-а) GameUnited 03.11.2025 в 09:24
Показов 2761 Комментарии 0
Метки c#, game design, gamedev, random, unity

Нажмите на изображение для увеличения
Название: Адаптивная случайность в Unity.jpg
Просмотров: 94
Размер:	80.1 Кб
ID:	11362
Мой знакомый геймдизайнер потерял двадцать процентов активной аудитории за неделю. А виновником оказался обычный генератор псевдослучайных чисел. Казалось бы - добавил в карточную игру случайное получение редких карт, честные пятьдесят на пятьдесят. Математика чистая, никаких подвохов. Только вот игроки начали валить толпами, крича о "накрученной" системе. Тот случай наглядно показал: человеческая психика и математическая случайность живут в параллельных мирах. Игрок открывает десять паков подряд без легендарки - и всё, система "сломана". Неважно, что по теории вероятностей такая серия абсолютно нормальна при шансе в десять процентов. Мозг видит закономерность там, где её нет, и включает защиту от обмана.

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

Когда Random становится врагом геймдизайна



Стандартный Random.Range убивает боевую систему тихо и методично. Критический урон с шансом пятнадцать процентов на бумаге выглядит разумно, но в реальности превращает каждый бой в рулетку. Босс может убить игрока случайной серией из трёх критов подряд - вероятность низкая, но не нулевая. И вот уже форум взрывается постами про "нечестный ИИ" и "читерских боссов".

Я тестировал одну экшен-РПГ, где разработчики честно реализовали двадцатипроцентный шанс промаха. Математически корректно - каждый пятый удар мимо. Только формула не учитывает человеческую память. Игрок промахнулся четыре раза подряд из шести атак? Система сломана, даже если следующие десять ударов попали. Мозг цепляется за негатив и игнорирует позитив - эволюционный механизм выживания, который геймдизу создаёт головную боль. Генерация лута добивает окончательно. Фермишь данж час, выбивая шмот с трёхпроцентным шансом - и ничего. Снова заходишь - опять пусто. Третий раз? Пустота. Чистый Random не знает про твои предыдущие попытки. Каждый заход начинается с тех же трёх процентов, будто ты впервые зашёл. Система честная? Абсолютно. Игрок бесится? Гарантированно.

Худший кейс - процедурная генерация уровней. Бросаешь кубик на каждую комнату: враги, ловушки, сокровища. Получаешь идеально сбалансированный случайный уровень в теории. На практике - половина прохождений невозможна из-за перегруза врагами в начале, четверть слишком простая, и только оставшиеся двадцать пять процентов играбельны. Тестировщики рвут волосы, пытаясь воспроизвести баги в "случайных" уровнях. Random не различает контекст. Ему всё равно, что игрок уже десять раз подряд получил одно и то же оружие. Алгоритму плевать на фрустрацию от шестой аптечки вместо патронов, когда здоровья полно. Генератор не помнит, что последние пять боёв закончились случайной смертью от критов.

Случайность генерации открытого ключа для ECDSA в .Net
Сижу, разбираюсь с ЭЦП, использую ECDSA в котором генерируется публичный и приватный ключ для...

Внедрить использование регулярных выражений для улучшения логики бота
Написал бота. Он парсит текст сообщения, а-ля: "Привет", ответ будет "Привет, как дела?". Но есть...

"Переименовать" дженерик свойство для улучшения семантики?
Здравствуйте, имеет ли смысл "переименовать" дженерик свойство, путем добавления нового, что бы его...

Есть тут кто пишет на C# для Unity? Под игры созданные в Unity читы делаются?
Привет. Есть тут кто пишет на C# для Unity? Под игры созданные в Unity читы делаются? Такое...


Адаптивные вероятности: суть концепции



Представьте систему, которая помнит. Не в смысле сохранения игры, а помнит ваши неудачи, успехи, серии провалов. И реагирует на это изменением шансов в реальном времени. Именно так работают адаптивные вероятности - механизм коррекции случайности на основе истории событий. Базовый принцип простой до неприличия. Игрок открыл три лутбокса без редкого предмета? Система запоминает и слегка повышает шанс в следующем. Ещё промах? Шанс ползёт вверх дальше. Получил награду? Счётчик обнуляется, возвращаемся к базовой вероятности. Никакой магии - обычная математика с памятью.

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

Ключевое отличие от статичного Random - учёт контекста. Обычный генератор бросает кубик в вакууме, каждый раз заново. Адаптивная система видит последовательность бросков и корректирует грани кубика под текущую ситуацию. Она не делает игру предсказуемой - случайность остаётся, но экстремальные серии неудач становятся математически менее вероятными.

Формула выглядит примерно так: базовая вероятность плюс модификатор, зависящий от количества неудачных попыток. Модификатор может быть линейным - добавляем фиксированный процент каждый раз. Или прогрессивным - каждая следующая неудача влияет сильнее предыдущей. Конкретная реализация зависит от типа игры и желаемого баланса между случайностью и прогнозируемостью. Главное коварство - найти баланс. Слишком агрессивная адаптация убивает случайность полностью, превращая систему в замаскированный таймер. Слишком мягкая не решает исходную проблему с фрустрацией от невезения. Тестирование показывает, что оптимум обычно лежит где-то между десятью и двадцатью процентами увеличения базовой вероятности за итерацию, но это сильно зависит от игры. Адаптивные вероятности не панацея. Они не исправят плохой баланс и не спасут скучную механику. Но они делают случайность человечнее, ближе к тому, как игроки интуитивно понимают справедливость. И это уже немало.

Динамическая корректировка шансов на основе истории событий



Механика отслеживания превращает тупой генератор чисел в систему с памятью. Каждое событие оставляет след - не просто отметку "было/не было", а структурированные данные для дальнейшего анализа. За несколько лет практики я пришёл к выводу: минимальный набор включает тип события, временную метку, исходную вероятность и конечный результат. Этого хватает для базовой адаптации, но серьёзные проекты требуют больше. Храним последние N событий в кольцевом буфере - проще некуда и производительность отличная. Размер буфера зависит от типа игры: для быстрого шутера хватит десяти-пятнадцати записей, для стратегии с долгими сессиями лучше брать сотню. Когда буфер заполнен, новое событие вытесняет самое старое. Система автоматически "забывает" древнюю историю, фокусируясь на недавних событиях - именно они влияют на текущее восприятие игрока.

Расчёт модификатора начинается с подсчёта серии неудач. Простейший вариант - линейное увеличение: каждая неудача добавляет фиксированный процент к базовой вероятности. Работает стабильно, предсказуемо, легко балансируется. Но скучновато. Прогрессивная шкала интереснее - первая неудача даёт плюс пять процентов, вторая десять, третья пятнадцать. Система быстрее реагирует на длинные серии провалов, защищая от критического невезения.

Экспоненциальный рост применяю редко - только когда нужно жёстко ограничить максимальную длину неудачной серии. Формула вида базовый_шанс * (1.2 ^ количество_неудач) разгоняет вероятность агрессивно. После пяти промахов шанс удваивается, после десяти утраивается. Опасная штука - легко перегнуть и превратить случайность в гарантию.

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

Критический нюанс - порог активации адаптации. Не каждая неудача должна запускать коррекцию, иначе система станет слишком отзывчивой. Я ставлю минимум три-четыре промаха подряд перед началом изменения вероятностей. Это сохраняет ощущение случайности при коротких сериях невезения.

Отличия от псевдослучайности и weighted random



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

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

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

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

Ключевое различие в философии. Псевдослучайность имитирует идеальную случайность. Взвешенная случайность настраивает базовые шансы. Адаптивные вероятности подстраиваются под конкретного игрока в конкретной ситуации. Это три разных инструмента для трёх разных задач, и попытка заменить один другим обычно заканчивается болью.

Математическое обоснование: теория вероятностей встречается с UX



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

Адаптивная система строится на модификации цепей Маркова. Классическая марковская цепь не помнит историю - вероятность следующего состояния зависит только от текущего. Мы добавляем память: вероятность события зависит не от одного предыдущего, а от последовательности из N предыдущих состояний. Получается гибрид между марковским процессом и скользящим окном наблюдений.

Формула базовой адаптации выглядит так: https://www.cyberforum.ru/cgi-bin/latex.cgi?P_{adjusted} = P_{base} + k \cdot f(n), где https://www.cyberforum.ru/cgi-bin/latex.cgi?P_{base} - исходная вероятность события, https://www.cyberforum.ru/cgi-bin/latex.cgi?k - коэффициент агрессивности адаптации, https://www.cyberforum.ru/cgi-bin/latex.cgi?f(n) - функция от количества последовательных неудач. Самая безопасная вариация - линейная функция https://www.cyberforum.ru/cgi-bin/latex.cgi?f(n) = n \cdot s, где https://www.cyberforum.ru/cgi-bin/latex.cgi?s - шаг увеличения вероятности. Если базовый шанс криптического удара пятнадцать процентов, https://www.cyberforum.ru/cgi-bin/latex.cgi?k = 0.5, https://www.cyberforum.ru/cgi-bin/latex.cgi?s = 0.02, то после трёх промахов получаем https://www.cyberforum.ru/cgi-bin/latex.cgi?0.15 + 0.5 \cdot (3 \cdot 0.02) = 0.18, то есть восемнадцать процентов.

Экспоненциальная функция https://www.cyberforum.ru/cgi-bin/latex.cgi?f(n) = a^n - 1 даёт более агрессивный рост, но требует тщательной настройки параметра https://www.cyberforum.ru/cgi-bin/latex.cgi?a. При https://www.cyberforum.ru/cgi-bin/latex.cgi?a = 1.15 третья неудача даёт прирост в полтора раза, пятая - в два. Опасная территория - легко получить гарантированный успех после небольшой серии провалов.

Критический момент - определение верхней границы. Без неё система может разогнать вероятность до абсурдных величин. Я использую формулу https://www.cyberforum.ru/cgi-bin/latex.cgi?P_{max} = \min(P_{base} \cdot m, 1.0), где https://www.cyberforum.ru/cgi-bin/latex.cgi?m - множитель максимального роста. Обычно беру значения между двумя и тремя - вероятность может вырасти максимум вдвое-втрое от исходной, но не до ста процентов.

UX-компонента вступает в игру через теорию ожиданий. Игрок формирует представление о справедливости на основе короткой выборки, а не бесконечной последовательности испытаний. Адаптивная система сжимает дисперсию результатов - экстремальные отклонения становятся редкими. Математически это выражается через уменьшение стандартного отклонения распределения серий неудач. Если в чистом Random стандартное отклонение длины серии составляет корень из N испытаний, то адаптивная система снижает его на двадцать-сорок процентов в зависимости от агрессивности коррекции.

Проверить эффективность можно через симуляцию. Запускаешь миллион итераций с обычным Random и со адаптивной системой, строишь гистограммы длин серий неудач. У Random получается классическое геометрическое распределение с длинным хвостом - серии из десяти-пятнадцати промахов редкие, но присутствуют. Адаптивная система обрезает хвост, делая длинные серии математически невозможными. Игрок это не осознаёт, но чувствует - система стала "справедливее".

Связь с теорией игр и принятием решений в условиях неопределенности



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

Классическая дилемма: фармить босса дальше или переключиться на другую активность? При фиксированных вероятностях решение простое - считаешь математическое ожидание награды и сравниваешь с альтернативами. Адаптивная система ломает эту логику. Каждая неудачная попытка увеличивает ценность следующей, создавая эффект невозвратных затрат. Игрок думает: "Ещё пять попыток без легенды, значит, шанс сейчас выше обычного, глупо бросать". Система мягко подталкивает продолжать, даже когда рационально стоило бы остановиться. Асимметрия информации играет ключевую роль. Игрок не знает точных формул коррекции - он видит только результаты и строит гипотезы. Это минус или плюс? Смотря с какой стороны. Полная прозрачность убивает магию - когда игрок точно знает, что после десяти неудач идёт гарантия, система превращается в скучный таймер. Непрозрачность сохраняет иллюзию случайности, но добавляет фрустрации от невозможности планировать наверняка.

Я тестировал вариант с частичной прозрачностью - индикатор "удачи", который рос с каждой неудачей. Игроки быстро поняли механику и начали откладывать важные попытки до момента, когда индикатор максимален. Задумка провалилась - вместо плавного опыта получилась механика накопления и траты ресурса. Убрали индикатор, оставили только незаметные визуальные эффекты при повышенных шансах - ситуация выправилась.

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

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

Реализация в Unity: базовый подход



Unity позволяет реализовать адаптивные вероятности за пару часов - если знаешь, куда копать. Первая попытка обычно проваливается из-за неочевидной ловушки: хочется запихнуть всю логику в MonoBehaviour, привязать к GameObject и радоваться жизни. Только вот адаптивная система должна жить независимо от сцены, переживать переходы между уровнями и сохраняться вместе с прогрессом игрока. MonoBehaviour тут не помощник - нужен обычный C# класс без Unity-специфики.

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

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class AdaptiveProbabilityManager
{
    private static AdaptiveProbabilityManager _instance;
    public static AdaptiveProbabilityManager Instance => _instance ??= new AdaptiveProbabilityManager();
 
    // Хранилище счётчиков неудач для разных типов событий
    private Dictionary<string, int> _failureStreaks = new Dictionary<string, int>();
    
    // Настройки адаптации
    private const float BASE_INCREASE_PER_FAILURE = 0.05f; // 5% за каждую неудачу
    private const float MAX_MULTIPLIER = 3.0f; // Максимум втрое от базовой вероятности
 
    public float GetAdjustedProbability(string eventType, float baseProbability)
    {
        if (!_failureStreaks.ContainsKey(eventType))
            _failureStreaks[eventType] = 0;
 
        int failures = _failureStreaks[eventType];
        float increase = baseProbability * BASE_INCREASE_PER_FAILURE * failures;
        float adjusted = Mathf.Min(baseProbability + increase, baseProbability * MAX_MULTIPLIER);
        
        return Mathf.Clamp01(adjusted); // Ограничиваем диапазоном [0, 1]
    }
 
    public void RegisterResult(string eventType, bool success)
    {
        if (success)
        {
            _failureStreaks[eventType] = 0; // Сброс при успехе
        }
        else
        {
            if (!_failureStreaks.ContainsKey(eventType))
                _failureStreaks[eventType] = 0;
            
            _failureStreaks[eventType]++;
        }
    }
}
Использование предельно простое. Перед генерацией случайного события запрашиваешь скорректированную вероятность, бросаешь Random, затем регистрируешь результат. Система сама отследит серии и подкрутит шансы.

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class LootDropper : MonoBehaviour
{
    [SerializeField] private float _baseLegendaryChance = 0.05f; // 5% базовый шанс
 
    public void TryDropLegendary()
    {
        float adjustedChance = AdaptiveProbabilityManager.Instance
            .GetAdjustedProbability("legendary_drop", _baseLegendaryChance);
        
        bool dropped = Random.value <= adjustedChance;
        
        if (dropped)
        {
            // Создаём легендарный предмет
            SpawnLegendaryItem();
        }
        
        // Регистрируем результат для корректировки будущих попыток
        AdaptiveProbabilityManager.Instance.RegisterResult("legendary_drop", dropped);
    }
}
Главный недостаток этой базовой версии - отсутствие персистентности. При рестарте игры все счётчики обнуляются. Но для прототипа и тестирования механики этого хватает с головой. Сохранение добавить несложно - обернуть словарь в сериализуемый класс и записать через JsonUtility или собственную систему сохранений.
Заметил странность: некоторые разработчики пытаются передавать сам объект события вместо строкового ключа. Выглядит круто, но ломается при попытке сериализации - Unity не умеет сохранять произвольные объекты. Строковые идентификаторы примитивны, зато надёжны и легко отлаживаются в инспекторе.

Класс AdaptiveProbabilityManager



Базовая версия менеджера работает, но в бою быстро вылезают недостатки. Первая проблема - отсутствие гибкости в настройке коэффициентов. Хардкодить константы в теле класса удобно для прототипа, но превращается в кошмар при балансировке. Каждое изменение требует перекомпиляции, а дизайнерам нужно дёргать программиста для малейшей правки. Решается через ScriptableObject с параметрами адаптации - дизайнеры крутят циферки в инспекторе, код остаётся нетронутым.

C#
1
2
3
4
5
6
7
8
9
10
11
12
[CreateAssetMenu(fileName = "AdaptiveSettings", menuName = "Game/Adaptive Settings")]
public class AdaptiveSettings : ScriptableObject
{
    [Range(0f, 0.2f)]
    public float increasePerFailure = 0.05f; // Прирост за неудачу
    
    [Range(1.5f, 5f)]
    public float maxMultiplier = 3.0f; // Максимальный множитель
    
    [Range(0, 10)]
    public int minFailuresBeforeAdaptation = 2; // Порог активации
}
Второй косяк проявляется при работе с разными категориями событий. Легендарный дроп и критический удар требуют разных настроек агрессивности - первый может ждать дольше, второй бесит быстрее. Тащить везде строковые ключи неудобно и чревато опечатками. Я перешёл на enum с типами событий и словарь настроек для каждого типа.

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public enum AdaptiveEventType
{
    LegendaryDrop,
    CriticalHit,
    RareResource,
    BossLoot
}
 
public class AdaptiveProbabilityManager
{
    private Dictionary<AdaptiveEventType, int> _failureStreaks;
    private Dictionary<AdaptiveEventType, AdaptiveSettings> _settings;
    
    public void ConfigureEvent(AdaptiveEventType eventType, AdaptiveSettings settings)
    {
        _settings[eventType] = settings;
    }
    
    public float GetAdjustedProbability(AdaptiveEventType eventType, float baseProbability)
    {
        if (!_settings.ContainsKey(eventType))
        {
            // Запасной вариант, если настройки не заданы
            return baseProbability;
        }
        
        int failures = _failureStreaks.GetValueOrDefault(eventType, 0);
        var config = _settings[eventType];
        
        // Адаптация активируется только после порога
        if (failures < config.minFailuresBeforeAdaptation)
            return baseProbability;
        
        float increase = baseProbability * config.increasePerFailure * 
                        (failures - config.minFailuresBeforeAdaptation);
        float adjusted = Mathf.Min(baseProbability + increase, 
                                   baseProbability * config.maxMultiplier);
        
        return Mathf.Clamp01(adjusted);
    }
}
Производительность становится узким местом, когда событий сотни в секунду. Видел проект с процедурной генерацией, где каждая плитка тайлмапа запрашивала адаптивную вероятность - генерация уровня тормозила на секунду-две. Виновник - Dictionary с постоянными поисками и выделением памяти под новые ключи. Оптимизация простая - кеширование часто используемых результатов и предварительное выделение capacity для словаря. Эксплойты всплывают неожиданно. Игрок понял механику и начал намеренно проваливать дешёвые события, чтобы разогнать счётчик для ценных попыток. Каждый тип событий должен жить в собственном контексте - счётчики изолированы друг от друга. Но даже это не панацея: в мультиплеере умники синхронизировали действия, создавая аномальные всплески адаптации. Пришлось добавить глобальный лимит на максимальную скорость роста вероятности за единицу времени.

Отслеживание истории выпадений



Счётчики неудач решают базовую задачу, но теряют контекст. Помню проект, где дизайнер жаловался: система адаптации работает странно после долгих игровых сессий. Оказалось, игрок мог фармить весь день, накопить огромный счётчик неудач, а потом получить серию гарантированных успехов подряд - баланс рушился. Проблема в том, что голый счётчик не знает, когда произошли события. Десять неудач за час - это одно, десять неудач за три дня игры - совсем другое.
Полноценное отслеживание истории требует структуры посложнее простого integer. Минимальный набор данных для события: тип, временная метка, исходная вероятность, результат. Можно добавить контекст - уровень игрока, локацию, сложность противника. Но каждый дополнительный байт множится на тысячи событий, пожирая память.

C#
1
2
3
4
5
6
7
8
9
10
11
public struct EventRecord
{
    public AdaptiveEventType Type;
    public float Timestamp; // Time.time в момент события
    public float BaseProbability;
    public bool Success;
    
    // Опционально - контекст для продвинутого анализа
    public int PlayerLevel;
    public string Location;
}
Кольцевой буфер превращает хранение в тривиальную задачу. Выделяешь массив фиксированного размера - скажем, на сто записей. Индекс текущей позиции бегает по кругу. Когда массив заполнен, новое событие затирает самое старое. Никаких аллокаций во время игры, предсказуемое потребление памяти, отличная производительность.

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private EventRecord[] _history;
private int _currentIndex;
private const int HISTORY_SIZE = 100;
 
public void RecordEvent(AdaptiveEventType type, float baseProbability, bool success)
{
    _history[_currentIndex] = new EventRecord
    {
        Type = type,
        Timestamp = Time.time,
        BaseProbability = baseProbability,
        Success = success
    };
    
    _currentIndex = (_currentIndex + 1) % HISTORY_SIZE; // Переход по кругу
}
List соблазняет простотой, но создаёт проблемы. При каждом Add возможна реаллокация внутреннего массива - микрозависания в неожиданные моменты. RemoveAt для удаления старых записей сдвигает элементы, пожирая процессорное время. Видел проект, где дизайнер хранил всю историю с начала игры в List - через пару часов игры каждая операция тормозила заметно. Временные метки открывают продвинутые возможности. Можешь отфильтровать только последний час событий, игнорируя древнюю историю. Или применить экспоненциальное затухание веса - события месячной давности почти не влияют на текущие вероятности. Реализуется одной формулой: вес = exp(-lambda * (текущее_время - временная_метка)), где lambda контролирует скорость забывания.

Компромиссный вариант - два уровня хранения. Горячий кеш на последние двадцать-тридцать событий в массиве, холодный архив в файле для долговременной статистики. Большинство запросов бьют в кеш, архив трогается только при сохранении прогресса или для аналитики. Производительность максимальная, данные не теряются.

Формулы расчета корректирующих коэффициентов



Линейная формула - первое, что приходит в голову: https://www.cyberforum.ru/cgi-bin/latex.cgi?P_{adj} = P_{base} + k \cdot n, где https://www.cyberforum.ru/cgi-bin/latex.cgi?n количество неудач, https://www.cyberforum.ru/cgi-bin/latex.cgi?k шаг увеличения. Работает стабильно, дизайнерам легко понять и настроить. Если базовый шанс дропа десять процентов, шаг два процента, то после пяти промахов получаем двадцать процентов. Предсказуемо до скуки, зато не стреляет в ногу.

Прогрессивная шкала добавляет динамики. Формула https://www.cyberforum.ru/cgi-bin/latex.cgi?P_{adj} = P_{base} + P_{base} \cdot k \cdot n^{1.5} разгоняется быстрее с каждой неудачей. Первый промах даёт минимальный прирост, пятый уже заметно подкручивает шанс. Степень 1.5 выбрана эмпирически - меньше слишком похоже на линейку, больше превращается в резкий скачок.

C#
1
2
3
4
5
6
public float CalculateProgressiveModifier(float baseProbability, int failures, float coefficient)
{
    // Используем степень 1.5 для плавного, но заметного ускорения
    float modifier = baseProbability * coefficient * Mathf.Pow(failures, 1.5f);
    return Mathf.Min(modifier, baseProbability * 3f); // Ограничиваем утроением
}
Экспоненциальная формула https://www.cyberforum.ru/cgi-bin/latex.cgi?P_{adj} = P_{base} \cdot a^n опасная штука. Видел проект, где дизайнер поставил https://www.cyberforum.ru/cgi-bin/latex.cgi?a = 1.3 и расстроился, обнаружив гарантированный дроп после восьми неудач. При десятипроцентном базовом шансе восьмая степень даёт 137% - абсурд. Использую экспоненту только с жёстким потолком и маленькой базой, обычно https://www.cyberforum.ru/cgi-bin/latex.cgi?a между 1.1 и 1.15.

Комбинированная формула соединяет подходы: https://www.cyberforum.ru/cgi-bin/latex.cgi?P_{adj} = P_{base} \cdot (1 + k_1 \cdot n + k_2 \cdot \ln(n + 1)). Линейная часть даёт базовый рост, логарифм добавляет прогрессию в начале и замедляется к концу. Звучит заумно, но в коде элементарно:

C#
1
2
3
4
5
6
7
8
9
10
public float CalculateCombinedModifier(float baseProbability, int failures)
{
    const float LINEAR_COEF = 0.04f;
    const float LOG_COEF = 0.08f;
    
    float linearPart = LINEAR_COEF * failures;
    float logPart = LOG_COEF * Mathf.Log(failures + 1);
    
    return baseProbability * (1f + linearPart + logPart);
}
Главная ошибка - забыть про верхний предел. Любая формула обязана ограничиваться Mathf.Clamp01() для вероятностей или множителем вроде baseProbability * MAX_MULTIPLIER. Без этого получишь шансы больше ста процентов и непредсказуемое поведение Random.

Тестировал десятки вариаций на симуляторе с миллионом итераций. Оптимум для большинства игр - прогрессивная формула со степенью от 1.3 до 1.7 и ограничением в два-три раза от базовой вероятности. Линейка хороша для медленных систем вроде сюжетных наград, экспонента - только для критических ситуаций типа гарантированного дропа редчайших предметов.

Сериализация и сохранение состояния адаптивной системы



Сохранение адаптивной системы превращается в головную боль, когда понимаешь объём данных. Словарь с парой десятков ключей - ерунда. Но стоит добавить полную историю событий с временными метками, контекстом и метаданными, как размер сохранения раздувается до нескольких килобайт на игрока. В мобильной игре это критично - облачные сохранения имеют жёсткие лимиты, а частые записи пожирают трафик. JsonUtility соблазняет простотой - помечаешь класс атрибутом [Serializable], вызываешь ToJson() и готово. Проблема в ограничениях: словари не поддерживаются из коробки, приватные поля игнорируются без [SerializeField], наследование работает криво. Для прототипа сойдёт, в продакшене придётся танцевать с обёртками.

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
[System.Serializable]
public class AdaptiveSaveData
{
    // JsonUtility не умеет словари, используем параллельные массивы
    public string[] eventTypes;
    public int[] failureStreaks;
    public float saveTime;
    
    public AdaptiveSaveData(Dictionary<AdaptiveEventType, int> streaks)
    {
        int count = streaks.Count;
        eventTypes = new string[count];
        failureStreaks = new int[count];
        
        int index = 0;
        foreach (var pair in streaks)
        {
            eventTypes[index] = pair.Key.ToString();
            failureStreaks[index] = pair.Value;
            index++;
        }
        
        saveTime = Time.realtimeSinceStartup;
    }
}
Binary Formatter манит компактностью и скоростью, но Unity его недолюбливает по соображениям безопасности. Да и кроссплатформенность страдает - сохранение с Windows может не прочитаться на Android. Видел проект, где использовали BinaryFormatter для PC-версии и переписывали всю сериализацию для мобилок. Двойная работа и двойное количество багов.

PlayerPrefs выглядит удобно для малых данных: SetInt(), SetString() и делов-то. Только хранить там массивы неудобно - приходится конкатенировать в строку и потом парсить обратно. Лимит на размер значения зависит от платформы. Зато автоматическое сохранение и простота отладки через реестр Windows или plist на iOS. Для прототипа использую именно его:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public void SaveToPlayerPrefs()
{
    foreach (var pair in _failureStreaks)
    {
        string key = $"adaptive_streak_{pair.Key}";
        PlayerPrefs.SetInt(key, pair.Value);
    }
    
    PlayerPrefs.SetFloat("adaptive_save_time", Time.realtimeSinceStartup);
    PlayerPrefs.Save(); // Явный flush на диск
}
 
public void LoadFromPlayerPrefs()
{
    foreach (AdaptiveEventType type in System.Enum.GetValues(typeof(AdaptiveEventType)))
    {
        string key = $"adaptive_streak_{type}";
        if (PlayerPrefs.HasKey(key))
        {
            _failureStreaks[type] = PlayerPrefs.GetInt(key);
        }
    }
}
Версионирование спасает при изменении структуры данных. Добавил новое поле в сохранение? Старые сохранения взорвутся при десериализации без миграции. Я храню номер версии схемы и проверяю при загрузке - если версия старая, прогоняю данные через цепочку миграторов. Накладно, зато избавляет от багрепортов "игра крашится после обновления". Частота сохранений требует компромиссов. Писать после каждого события убьёт производительность - дисковые операции медленные. Копить изменения и сбрасывать раз в минуту рискованно - краш приведёт к потере прогресса. Оптимум где-то посередине: flush при важных событиях (получение редкого предмета) плюс периодический автосейв раз в тридцать секунд. В OnApplicationPause обязательно сохраняю всё - мобильные игроки выходят внезапно.

Интеграция с событийной архитектурой Unity



Монолитный код убивает расширяемость на корню. Когда AdaptiveProbabilityManager напрямую дёргает методы системы дропа, UI и аналитики - проект превращается в спагетти-код за пару спринтов. События решают проблему элегантно: менеджер не знает, кто подписан на его оповещения, а подписчики не заботятся о внутренностях системы вероятностей.
UnityEvent подкупает интеграцией с инспектором. Дизайнер может связать событие с методами прямо в редакторе, без строчки кода. Правда, производительность страдает - каждый вызов проходит через рефлексию. Для нечастых событий типа получения легендарного предмета сойдёт, для критов в экшене лучше искать альтернативы.

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class AdaptiveProbabilityManager
{
[System.Serializable]
public class ProbabilityEvent : UnityEvent<AdaptiveEventType, float> { }
 
public ProbabilityEvent OnProbabilityChanged = new ProbabilityEvent();
public UnityEvent<AdaptiveEventType> OnStreakBroken = new UnityEvent<AdaptiveEventType>();
 
public float GetAdjustedProbability(AdaptiveEventType eventType, float baseProbability)
{
    // ... расчёт вероятности ...
    
    // Оповещаем подписчиков об изменении
    OnProbabilityChanged?.Invoke(eventType, adjustedProbability);
    
    return adjustedProbability;
}
}
C# events быстрее и чище - обычный делегат без Unity-обёртки. Подписка через +=, отписка через -=, никакой магии. Главная ловушка: забыть отписаться в OnDestroy. Мёртвые ссылки на уничтоженные MonoBehaviour провоцируют утечки памяти и странные null reference exceptions.

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class AdaptiveProbabilityManager
{
// Событие с двумя параметрами: тип события и новая вероятность
public event Action<AdaptiveEventType, float> ProbabilityAdjusted;
 
// Событие при успешном выпадении после серии неудач
public event Action<AdaptiveEventType, int> StreakEnded;
 
private void NotifyProbabilityChange(AdaptiveEventType type, float probability)
{
    ProbabilityAdjusted?.Invoke(type, probability);
}
}
 
// Использование в другом классе
public class LootUI : MonoBehaviour
{
private void OnEnable()
{
    AdaptiveProbabilityManager.Instance.ProbabilityAdjusted += UpdateLuckMeter;
}
 
private void OnDisable()
{
    AdaptiveProbabilityManager.Instance.ProbabilityAdjusted -= UpdateLuckMeter;
}
 
private void UpdateLuckMeter(AdaptiveEventType type, float probability)
{
    // Обновляем визуальный индикатор "удачи"
    if (type == AdaptiveEventType.LegendaryDrop)
        _luckSlider.value = probability;
}
}
ScriptableObject events выводят архитектуру на новый уровень. Создаёшь ассет с событием, любой скрипт может на него подписаться или вызвать - связи между объектами разрываются полностью. Менял UI-систему три раза, геймплейный код не трогал вообще. Накладно по памяти - каждое событие отдельный файл, но гибкость компенсирует.

Продвинутые техники



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

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

Адаптация на основе скользящего среднего и экспоненциального сглаживания



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

Скользящее окно решает проблему - учитываем только события за последние N секунд или M попыток. Реализуется через очередь с временными метками. Проверяем возраст каждого события, устаревшие выбрасываем из расчётов. Получается адаптация, чувствительная к интенсивности действий игрока.

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class TimeBasedAdaptiveManager
{
    private Queue<float> _eventTimestamps = new Queue<float>();
    private const float WINDOW_SECONDS = 60f; // Окно в 60 секунд
 
    public float GetRecentFailureRate()
    {
        float currentTime = Time.time;
        
        // Удаляем устаревшие события
        while (_eventTimestamps.Count > 0 && 
               currentTime - _eventTimestamps.Peek() > WINDOW_SECONDS)
        {
            _eventTimestamps.Dequeue();
        }
        
        // Возвращаем количество неудач за окно
        return _eventTimestamps.Count;
    }
 
    public void RecordFailure()
    {
        _eventTimestamps.Enqueue(Time.time);
    }
}
Экспоненциальное сглаживание работает хитрее - оно не отбрасывает старые данные полностью, а плавно снижает их влияние с течением времени. Формула https://www.cyberforum.ru/cgi-bin/latex.cgi?EMA_t = \alpha \cdot x_t + (1 - \alpha) \cdot EMA_{t-1} даёт больший вес свежим событиям и меньший старым. Параметр альфа контролирует скорость забывания: при 0.3 система быстро реагирует на изменения, при 0.1 сглаживает флуктуации и смотрит на долгосрочную тенденцию.

Тестировал оба подхода на рогалике с процедурной генерацией. Скользящее окно давало резкие колебания - игрок попал в неудачную серию комнат, система агрессивно подкрутила дроп, баланс качнулся в другую сторону. Экспоненциальное сглаживание работало мягче - вероятности менялись постепенно, без резких скачков. Комбинация обоих подходов оказалась оптимальной: скользящее окно для краткосрочной реакции, EMA для долгосрочных трендов.

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private float _emaFailureRate = 0f;
private const float EMA_ALPHA = 0.2f;
 
public void UpdateEMA(bool success)
{
    float outcome = success ? 0f : 1f; // Неудача = 1, успех = 0
    _emaFailureRate = EMA_ALPHA * outcome + (1f - EMA_ALPHA) * _emaFailureRate;
}
 
public float GetAdjustedProbability(float baseProbability)
{
    // Чем выше EMA (больше недавних неудач), тем сильнее коррекция
    float modifier = _emaFailureRate * 0.5f;
    return Mathf.Clamp01(baseProbability * (1f + modifier));
}
Главный косяк экспоненциального сглаживания - холодный старт. В начале игры EMA равен нулю, первые несколько событий почти не влияют на результат. Игрок может получить серию из трёх неудач подряд, а система даже не дёрнется. Решается инициализацией EMA в разумное начальное значение - обычно беру половину от ожидаемой частоты неудач при базовой вероятности.

Мультифакторная адаптация



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

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

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class MultifactorAdaptiveManager
{
    // Весовые коэффициенты для каждого фактора
    private const float FAILURE_WEIGHT = 0.4f;
    private const float PLAYER_LEVEL_WEIGHT = 0.15f;
    private const float SESSION_TIME_WEIGHT = 0.2f;
    private const float DIFFICULTY_WEIGHT = 0.25f;
    
    public float CalculateAdaptiveModifier(
        int failureStreak, 
        int playerLevel, 
        float sessionMinutes,
        float locationDifficulty)
    {
        // Нормализуем каждый фактор в диапазон [0, 1]
        float failureFactor = Mathf.Clamp01(failureStreak / 10f);
        float levelFactor = 1f - Mathf.Clamp01(playerLevel / 100f); // Инверсия: новички получают бонус
        float timeFactor = Mathf.Clamp01(sessionMinutes / 120f); // До 2 часов учитываем
        float difficultyFactor = Mathf.Clamp01(locationDifficulty);
        
        // Взвешенная сумма всех факторов
        float totalModifier = 
            failureFactor * FAILURE_WEIGHT +
            levelFactor * PLAYER_LEVEL_WEIGHT +
            timeFactor * SESSION_TIME_WEIGHT +
            difficultyFactor * DIFFICULTY_WEIGHT;
        
        return totalModifier; // Результат в интервале [0, 1]
    }
}
Помню проект, где внедрил адаптацию по восьми параметрам одновременно. Казалось - чем больше данных, тем лучше. На практике получилась непредсказуемая каша, где баланс ломался от малейшего изменения любого фактора. Откатился на три ключевых параметра, и система заработала как часы. Правило простое: больше трёх-четырёх факторов управлять невозможно без специализированных инструментов машинного обучения. Веса требуют постоянной балансировки. То, что работало в альфе, разваливается в бете после изменения прогрессии персонажа. Выделяю веса в ScriptableObject - дизайнеры крутят значения без касания кода, эксперименты идут быстрее. Телеметрия показывает, какие комбинации факторов коррелируют с оттоком игроков - там и копаю глубже.

Временные окна и затухание влияния



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

Затухание весов решает проблему математически изящно - каждое событие получает множитель, который уменьшается с течением времени. Формула https://www.cyberforum.ru/cgi-bin/latex.cgi?w(t) = e^{-\lambda \cdot \Delta t} превращает недельную неудачу в статистическую пыль, сохраняя полный вес для свежих событий. Параметр лямбда контролирует скорость забывания: при 0.1 событие теряет половину веса за семь часов, при 0.01 растягивается на трое суток.

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private const float DECAY_LAMBDA = 0.05f; // Полураспад примерно за 14 часов
 
public float CalculateDecayedWeight(float eventTimestamp)
{
    float deltaTime = Time.time - eventTimestamp;
    float weight = Mathf.Exp(-DECAY_LAMBDA * deltaTime);
    return weight; // Значение от 1.0 (свежее) до ~0 (древнее)
}
 
public float GetWeightedFailureCount()
{
    float weightedSum = 0f;
    
    foreach (var record in _eventHistory)
    {
        if (!record.Success)
        {
            float weight = CalculateDecayedWeight(record.Timestamp);
            weightedSum += weight;
        }
    }
    
    return weightedSum;
}
Пороговая модель работает проще - события старше определённого возраста отбрасываются полностью. Жёстко, зато понятно и предсказуемо. Использую для быстрых игровых сессий - всё, что случилось более часа назад, перестаёт существовать для адаптации. Экспоненциальное затухание оставляю для долгоиграющих проектов, где нужна плавная деградация влияния.

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

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public float CalculatePiecewiseWeight(float eventAge)
{
    const float FULL_WEIGHT_MINUTES = 10f;
    const float DECAY_MINUTES = 20f;
    
    if (eventAge < FULL_WEIGHT_MINUTES)
        return 1f;
    
    if (eventAge > FULL_WEIGHT_MINUTES + DECAY_MINUTES)
        return 0f;
    
    // Линейное падение в интервале
    float decayProgress = (eventAge - FULL_WEIGHT_MINUTES) / DECAY_MINUTES;
    return 1f - decayProgress;
}
Главная ошибка - забыть про сохранения. Временные метки привязаны к Time.time, который сбрасывается при рестарте. Сохраняй DateTime.UtcNow или Time.realtimeSinceStartup - они переживают перезапуски приложения.

Балансировка между предсказуемостью и случайностью



Слишком агрессивная адаптация убивает случайность на корню. Видел проект, где дизайнер так сильно подкручивал вероятности после неудач, что после пяти промахов следующий успех был практически гарантирован. Игроки быстро просекли механику - начали намеренно проваливать дешёвые попытки, чтобы гарантированно получить ценную награду. Система превратилась в замаскированный таймер: пять неудач = автоматический успех. Случайность испарилась полностью, осталась предсказуемая рутина. Обратная крайность - адаптация настолько мягкая, что незаметна вообще. Коэффициент роста 0.01 за неудачу при базовом шансе 10% даёт прирост в один процент после десяти промахов. Математически система работает, практически бесполезна - экстремальные серии невезения остаются такими же мучительными, как при чистом Random. Игрок не чувствует никакой разницы, фрустрация никуда не девается.

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

Я использую правило двух сигм: длина самой длинной серии не должна превышать среднюю длину плюс два стандартных отклонения. Для базовой вероятности 10% средняя серия около десяти попыток, стандартное отклонение примерно три. Значит, адаптация должна резать серии длиннее шестнадцати попыток, но не раньше. Это сохраняет ощущение случайности для большинства попыток и защищает от критического невезения. Тестирование с живыми игроками показывает парадокс: люди не чувствуют разницу между 15% и 20% шансом, но отлично замечают гарантированные исходы. Порог заметности лежит где-то около 30-40% изменения вероятности - меньше воспринимается как обычная флуктуация, больше начинает пахнуть манипуляцией. Оптимум для невидимой адаптации: максимальное увеличение в полтора-два раза от базовой вероятности, растянутое на десяток-полтора неудачных попыток. Работает незаметно, но эффективно.

Гибридные модели: комбинирование нескольких стратегий адаптации



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

Решение пришло само собой: использовать разные стратегии для разных ситуаций. Короткие серии неудач (до пяти штук) обрабатывались линейной адаптацией - мягкой, почти незаметной. Средние серии (от шести до пятнадцати) переключали систему на прогрессивную формулу с ускоряющимся ростом. Экстремальные случаи (больше пятнадцати неудач) активировали агрессивную экспоненциальную коррекцию - игрок явно словил полосу невезения, надо помочь быстро и решительно.

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public float GetHybridProbability(float baseProbability, int failureCount)
{
    float modifier;
    
    // Короткие серии: линейная, мягкая
    if (failureCount <= 5)
    {
        modifier = baseProbability * 0.02f * failureCount;
    }
    // Средние серии: прогрессивная
    else if (failureCount <= 15)
    {
        float adjusted = failureCount - 5;
        modifier = baseProbability * (0.1f + 0.05f * Mathf.Pow(adjusted, 1.3f));
    }
    // Экстремальные: экспоненциальная
    else
    {
        float excess = failureCount - 15;
        modifier = baseProbability * (1f + Mathf.Pow(1.2f, excess));
    }
    
    return Mathf.Clamp01(baseProbability + modifier);
}
Второй подход - параллельная работа нескольких стратегий одновременно. Одна смотрит на количество неудач, вторая анализирует временные интервалы между попытками, третья учитывает общий прогресс игрока. Каждая выдаёт свой модификатор, результаты объединяются через взвешенную сумму или берётся максимум. Звучит сложно, но гибкость возрастает многократно.

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

Главный риск гибридных моделей - сложность отладки. Когда три формулы работают одновременно, вычленить источник проблемы превращается в квест. Я выводил в лог каждую компоненту финального модификатора с отдельной меткой - так хотя бы видел, какая именно стратегия дала сбой. Визуализация через графики помогала ещё сильнее: строил в редакторе кривые изменения вероятности для разных комбинаций параметров, баги вылезали сразу.

Защита от edge-случаев: граничные условия и аномалии



Первый краш системы произошёл через два часа после релиза. Игрок умудрился получить отрицательный счётчик неудач - да, минус три промаха. Как? Багнутый квест выдавал награду дважды, система регистрировала два успеха подряд без промежуточных попыток, счётчик уходил в минус от вычитаний. Следующий запрос вероятности множил отрицательное число на коэффициент, и Unity выплёвывал NaN. Весь дроп в игре сломался за секунду.

Нулевая базовая вероятность создаёт не менее весёлую ситуацию. Формула https://www.cyberforum.ru/cgi-bin/latex.cgi?P_{adj} = P_{base} \cdot (1 + k \cdot n) при нулевом базовом шансе выдаёт ноль при любом количестве неудач. Ноль умноженный на что угодно остаётся нулём - адаптация мертва, игрок никогда не получит событие. Видел дизайнера, который выставил шанс эпического дропа в ноль "временно, для тестов" и забыл вернуть. Адаптивная система честно пыталась помочь невезучим игрокам, умножая ноль на растущие коэффициенты.

Переполнение счётчика вылазит при длительных игровых сессиях. Integer переполняется после двух миллиардов неудач - звучит безопасно, правда? Только вот я храню миллисекунды в целочисленной временной метке. Сто часов игры дают триста шестьдесят миллионов миллисекунд. Ещё пару недель фарма - и счётчик улетает в отрицательную зону. Решение тривиальное - использовать long или float для времени.

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public float GetSafeProbability(float baseProbability, int failureCount)
{
    // Защита от отрицательных счётчиков
    if (failureCount < 0)
    {
        Debug.LogWarning($"Отрицательный счётчик неудач: {failureCount}. Сброс в ноль.");
        failureCount = 0;
    }
    
    // Защита от нулевой базовой вероятности
    if (baseProbability <= 0f)
    {
        Debug.LogWarning($"Нулевая базовая вероятность для '{failureCount}' неудач");
        return 0f; // Адаптация бесполезна
    }
    
    // Ограничение счётчика для предотвращения переполнений
    failureCount = Mathf.Min(failureCount, 1000);
    
    float modifier = baseProbability * 0.05f * failureCount;
    float adjusted = baseProbability + modifier;
    
    // Финальная проверка на валидность
    if (float.IsNaN(adjusted) || float.IsInfinity(adjusted))
    {
        Debug.LogError("Получен невалидный результат расчёта вероятности!");
        return baseProbability;
    }
    
    return Mathf.Clamp01(adjusted);
}
Одновременный доступ из разных потоков разваливает счётчики на мобильных устройствах. Два события приходят одновременно, оба читают значение десять, оба увеличивают на единицу, оба записывают одиннадцать. Потеряли одну неудачу - мелочь, но за час таких потерь набирается десяток. Примитивы синхронизации решают, но жрут производительность. Оптимум - атомарные операции для критичных счётчиков, остальное пускать асинхронно с риском редких рассинхронов.

Практические сценарии применения



Самое очевидное применение - лут-системы. Открываешь сундук, убиваешь босса, завершаешь квест - всюду работает генератор случайных наград. Именно здесь фрустрация от невезения бьёт сильнее всего, потому что игрок потратил время и ресурсы. Видел аналитику мобильной RPG: сорок процентов негативных отзывов упоминали "поломанный" дроп редких предметов. Внедрение адаптивных вероятностей снизило жалобы наполовину за месяц без изменения базовых шансов. Боевые системы требуют другого подхода. Критические удары, промахи, блоки - события частые, но каждое влияет на исход боя меньше, чем легендарный дроп. Тут адаптация работает тоньше: не даём игроку умереть от случайной серии критов противника, но сохраняем остроту схватки. Формулы корректируются на лету в зависимости от здоровья персонажа и важности момента.

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

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

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

Лутбоксы и дроп предметов



Лутбоксы - минное поле для репутации игры. Игрок потратил двадцать долларов на кристаллы, открыл десять боксов подряд - один мусор. Он не думает про математику или закон больших чисел. Он думает, что его обманули. И пишет гневный отзыв с одной звездой, прикрепляя скрины "доказательства" накрутки. Видел проект, который потерял позиции в топе из-за волны таких отзывов, хотя система работала математически честно.

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

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class LootBoxManager
{
    private const float BASE_LEGENDARY_CHANCE = 0.05f;
    private const float PITY_INCREMENT = 0.005f;
    private const int GUARANTEED_AT = 90; // Жёсткая гарантия
 
    public Item OpenLootBox()
    {
        int streak = AdaptiveProbabilityManager.Instance
            .GetFailureStreak("legendary_loot");
        
        // Гарантия на 90-й попытке без легенды
        if (streak >= GUARANTEED_AT)
        {
            AdaptiveProbabilityManager.Instance
                .RegisterResult("legendary_loot", true);
            return GenerateLegendaryItem();
        }
        
        float adjustedChance = BASE_LEGENDARY_CHANCE + 
                              (streak * PITY_INCREMENT);
        adjustedChance = Mathf.Clamp01(adjustedChance);
        
        bool isLegendary = Random.value <= adjustedChance;
        AdaptiveProbabilityManager.Instance
            .RegisterResult("legendary_loot", isLegendary);
        
        return isLegendary ? GenerateLegendaryItem() : GenerateCommonItem();
    }
}
Критический момент - жёсткая гарантия. После определённого количества неудач даём легендарку принудительно, без броска генератора. Это называется "pity system" в индустрии, и геймдизайнеры жарко спорят про правильное число попыток. Девяносто - стандарт для гача-игр, пятьдесят для менее жадных проектов, тридцать для казуальщины. Главное - открыто не заявлять точное число, иначе игроки будут копить ресурсы до гарантии.

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

Критические удары в боевых системах



Критические удары в боевке - классический генератор соли. Босс выдал три крита подряд, игрок лежит мёртвый, на форуме уже пылает тред "читерский ИИ". Математически всё честно: пятнадцатипроцентный шанс даёт 0.15³ ≈ 0.34% вероятности тройного крита. Редко, но случается сотни раз в день на миллионной аудитории. И каждый такой игрок уверен - систему подкрутили специально против него.

Обратная ситуация не лучше. Игрок молотит босса полминуты, ни одного крита за двадцать ударов. Шанс двадцать процентов, а серия из двадцати промахов даёт вероятность 0.8²⁰ ≈ 1.15%. Тоже редкость, но попадаются и такие невезучие. Бой превращается в унылое долбление, динамика умирает, адреналин испаряется.

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

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class CombatCritSystem
{
private const float PLAYER_BASE_CRIT = 0.2f;
private const float ENEMY_BASE_CRIT = 0.15f;
 
public bool CheckPlayerCrit(string playerId)
{
    int noLuckStreak = AdaptiveProbabilityManager.Instance
        .GetFailureStreak($"player_crit_{playerId}");
    
    // Мягкое увеличение - по 3% за каждый промах
    float bonus = noLuckStreak * 0.03f;
    float chance = Mathf.Min(PLAYER_BASE_CRIT + bonus, 0.5f); // Максимум 50%
    
    bool isCrit = Random.value <= chance;
    AdaptiveProbabilityManager.Instance
        .RegisterResult($"player_crit_{playerId}", isCrit);
    
    return isCrit;
}
 
public bool CheckEnemyCrit(Enemy enemy, float playerHealthPercent)
{
    int critStreak = AdaptiveProbabilityManager.Instance
        .GetSuccessStreak($"enemy_crit_{enemy.Id}"); // Считаем успехи, не неудачи!
    
    // Снижаем шанс после критов
    float penalty = critStreak * 0.05f;
    float chance = Mathf.Max(ENEMY_BASE_CRIT - penalty, 0.05f); // Минимум 5%
    
    // Дополнительное снижение при низком здоровье игрока
    if (playerHealthPercent < 0.3f)
        chance *= 0.7f; // -30% к шансу крита
    
    bool isCrit = Random.value <= chance;
    AdaptiveProbabilityManager.Instance
        .RegisterResult($"enemy_crit_{enemy.Id}", isCrit);
    
    return isCrit;
}
}
Асимметрия критична для геймплея. Игрок должен чувствовать прогрессию - его криты растут в цене после неудачных атак. Противники получают противоположную механику - повторные критические удары становятся редкими, отсекая мгновенные убийства. Здоровье игрока добавляет ещё один слой защиты: на низком HP вражеские критические шансы падают дополнительно - последний шанс спастись от "несправедливой" смерти. Промахи требуют отдельной адаптации. Видел экшен, где базовый шанс промаха составлял десять процентов. Звучит разумно, пока не получишь четыре промаха из пяти атак. Система должна жёстко ограничивать максимальную серию: после двух промахов подряд шанс третьего стремится к нулю через резкую коррекцию. Игрок теряет урон от двух пропущенных ударов - этого достаточно для создания напряжения, третий промах уже издевательство.

Процедурная генерация уровней



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

Чистая случайность не понимает контекста последовательности. Генератор честно выдал комнату с боссом? Отлично, на следующей итерации он с тем же базовым шансом может выдать ещё одного. И ещё. Теоретически возможна цепочка из десяти боссов подряд - вероятность микроскопическая, но на миллионе прохождений кто-то словит. А один такой баг-репорт со скрином убивает доверие к генерации навсегда.

Адаптивная система запоминает, что породила последние пять-семь комнат, и корректирует вероятности для следующей. Выпала комната с боссом? Шанс повторного босса падает к нулю на ближайшие десять комнат. Три коридора подряд без событий? Вероятность встретить врагов или сокровища удваивается. Генерация остаётся случайной в масштабе уровня, но локально становится предсказуемее и комфортнее.

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public class AdaptiveRoomGenerator
{
    private const float BASE_ENEMY_CHANCE = 0.4f;
    private const float BASE_TRAP_CHANCE = 0.3f;
    private const float BASE_TREASURE_CHANCE = 0.2f;
    
    public RoomType GenerateNext()
    {
        int emptyStreak = AdaptiveProbabilityManager.Instance
            .GetFailureStreak("content_room");
        
        // После трёх пустых комнат резко повышаем шанс контента
        float contentBonus = emptyStreak >= 3 ? 0.3f : 0f;
        
        float enemyChance = BASE_ENEMY_CHANCE + contentBonus;
        float trapChance = BASE_TRAP_CHANCE + contentBonus * 0.5f;
        float treasureChance = BASE_TREASURE_CHANCE + contentBonus * 0.7f;
        
        float roll = Random.value;
        
        if (roll < enemyChance)
        {
            AdaptiveProbabilityManager.Instance
                .RegisterResult("content_room", true);
            return RoomType.Enemy;
        }
        
        if (roll < enemyChance + trapChance)
        {
            AdaptiveProbabilityManager.Instance
                .RegisterResult("content_room", true);
            return RoomType.Trap;
        }
        
        if (roll < enemyChance + trapChance + treasureChance)
        {
            AdaptiveProbabilityManager.Instance
                .RegisterResult("content_room", true);
            return RoomType.Treasure;
        }
        
        // Пустая комната считается "неудачей" для адаптации
        AdaptiveProbabilityManager.Instance
            .RegisterResult("content_room", false);
        return RoomType.Empty;
    }
}
Плотность контента регулируется на лету без ручной расстановки. Игрок встретил три напряжённых боя подряд - система даёт передышку, снижая частоту врагов. Прошёл длинный пустой коридор - следующая комната гарантированно интересная. Баланс поддерживается автоматически, дизайнеру не нужно вручную прописывать последовательности.

Системы прогрессии персонажа и распределение наград



Прокачка персонажа разваливается от чистого Random быстрее, чем боевка. Игрок тратит два часа на фарм опыта, получает новый уровень - и система выдаёт точно такой же перк, что и три уровня назад. Дубликат бесполезен, мотивация рушится мгновенно. Математически всё верно: пять доступных перков, каждый с двадцатипроцентным шансом. Только вот повторение убивает ощущение прогресса полностью, даже когда происходит по законам статистики. Работал над action-RPG, где дизайнер раздавал случайные модификаторы за каждый десятый уровень. Шесть типов бонусов, честный Random.Next(). Через месяц тестирования обнаружили катастрофу: треть игроков получила трёхкратное усиление здоровья к сороковому уровню, превратившись в неубиваемых танков. Другая треть фармила скорость атаки - баланс рухнул. Проблема не в формуле распределения, а в отсутствии памяти о предыдущих выборах. Генератор выдавал результат, не зная историю наград конкретного персонажа.

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

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class ProgressionRewardSystem
{
    private Dictionary<RewardType, int> _receivedRewards = new();
    
    public RewardType SelectLevelUpReward(List<RewardType> availableRewards)
    {
        // Вычисляем веса с учётом истории получения
        Dictionary<RewardType, float> weights = new();
        
        foreach (var reward in availableRewards)
        {
            int timesReceived = _receivedRewards.GetValueOrDefault(reward, 0);
            
            // Базовый вес 1.0, снижается экспоненциально с каждым получением
            float weight = Mathf.Pow(0.4f, timesReceived);
            weights[reward] = Mathf.Max(weight, 0.05f); // Минимум 5%, чтобы не обнулять
        }
        
        // Weighted random с адаптивными весами
        float totalWeight = weights.Values.Sum();
        float roll = Random.value * totalWeight;
        float accumulated = 0f;
        
        foreach (var pair in weights)
        {
            accumulated += pair.Value;
            if (roll <= accumulated)
            {
                _receivedRewards[pair.Key] = _receivedRewards.GetValueOrDefault(pair.Key, 0) + 1;
                return pair.Key;
            }
        }
        
        return availableRewards[0]; // Запасной вариант
    }
}
Нюанс скрывается в балансе между разнообразием и специализацией. Полный запрет повторов заставляет игрока брать слабые перки, когда сильный уже получен трижды. Мягкое снижение вероятности оставляет возможность сфокусироваться на любимом билде, но делает это дороже - придётся переждать получение других бонусов между повторами ключевого.
Сила награды влияет на агрессивность снижения веса. Редкие мощные перки должны иметь более жёсткое ограничение повторов - множитель 0.3 вместо 0.4 делает второе получение вдвое менее вероятным. Обычные бонусы пусть повторяются чаще, они менее критичны для баланса. Так система защищает от эксплойтов, когда игрок фармит один сломанный перк до абсурдных значений.

Адаптивный AI противников: динамическая сложность



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

Адаптивные вероятности превращают тупого бота в гибкого противника без переписывания логики ИИ. Базовая точность остаётся прежней, но корректируется в реальном времени в зависимости от успехов игрока. Проиграл три раунда подряд? Точность врагов падает на двадцать процентов, давая передышку. Выиграл пять матчей кряду? Боты начинают целиться точнее, восстанавливая баланс. Система незаметно подстраивается под конкретного человека за мышкой.

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

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class AdaptiveEnemyAI
{
    private const float BASE_ACCURACY = 0.7f;
    
    public bool TryHitPlayer(float playerHealthPercent, int playerWinStreak)
    {
        // Снижаем точность при низком здоровье игрока
        float healthModifier = playerHealthPercent < 0.3f ? -0.2f : 0f;
        
        // Повышаем после серии побед игрока
        float streakModifier = playerWinStreak * 0.05f;
        streakModifier = Mathf.Clamp(streakModifier, -0.15f, 0.2f);
        
        float adjustedAccuracy = BASE_ACCURACY + healthModifier + streakModifier;
        adjustedAccuracy = Mathf.Clamp(adjustedAccuracy, 0.3f, 0.9f); // Диапазон 30-90%
        
        return Random.value <= adjustedAccuracy;
    }
    
    public float GetAbilityCooldown(float combatDuration)
    {
        const float BASE_COOLDOWN = 15f;
        
        // Ускоряем способности в затянувшихся боях
        if (combatDuration > 180f)
            return BASE_COOLDOWN * 0.6f; // -40% время восстановления
        
        if (combatDuration > 120f)
            return BASE_COOLDOWN * 0.8f;
        
        return BASE_COOLDOWN;
    }
}
Опасность скрывается в чрезмерной помощи. Игрок быстро чувствует, когда система начинает откровенно поддаваться. Враги внезапно стреляют мимо десять раз подряд - это не адаптация, это оскорбление интеллекта. Модификаторы должны работать в узком коридоре: плюс-минус пятнадцать-двадцать процентов от базовых значений. Больше - и система становится заметной, разрушая погружение. Игрок хочет победить сам, а не получить победу из жалости алгоритма.

Спавн врагов и волновые механики



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

Волновые защиты требуют прогрессии сложности, но с элементом неожиданности. Пятая волна должна быть тяжелее третьей - это базовое ожидание игрока. Но внутри диапазона сложности полезно варьировать состав: то больше быстрых врагов, то несколько танков, то смешанная группа. Чистая случайность игнорирует контекст - она может выдать три волны подряд исключительно из летающих юнитов, на которые у игрока нет контры. Адаптивная система отслеживает состав последних нескольких волн и корректирует вероятности появления разных типов врагов. Три волны без танков? Шанс их появления удваивается в следующей. Игрок построил много противовоздушных башен? Система снижает частоту летунов, иначе инвестиция в оборону обесценивается.

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class AdaptiveWaveSpawner
{
    private Dictionary<EnemyType, int> _lastWavesCounts = new();
    private const int WAVE_MEMORY = 3;
    
    public List<Enemy> GenerateWave(int waveNumber)
    {
        float budget = waveNumber * 10f; // Общая "стоимость" врагов растёт
        List<Enemy> wave = new();
        
        while (budget > 0)
        {
            EnemyType type = SelectEnemyType();
            wave.Add(CreateEnemy(type));
            budget -= GetEnemyCost(type);
            
            _lastWavesCounts[type] = _lastWavesCounts.GetValueOrDefault(type, 0) + 1;
        }
        
        return wave;
    }
    
    private EnemyType SelectEnemyType()
    {
        // Снижаем вес типов, которые часто появлялись
        Dictionary<EnemyType, float> weights = new();
        
        foreach (EnemyType type in System.Enum.GetValues(typeof(EnemyType)))
        {
            int recentCount = _lastWavesCounts.GetValueOrDefault(type, 0);
            float weight = 1f / (1f + recentCount * 0.3f); // Чем больше было, тем меньше вес
            weights[type] = weight;
        }
        
        return WeightedRandom(weights);
    }
}
Темп подачи волн адаптируется под скорость реакции игрока. Справился с волной за двадцать секунд до таймера? Следующая придёт на пять секунд раньше, повышая напряжение. Едва выжил в последние секунды? Добавляем передышку - ещё десять секунд на подготовку. Небольшие корректировки, но они делают сложность плавной вместо ступенчатой.

Эксплуатация системы игроками



Игроки ломают системы с энтузиазмом археологов, раскапывающих древние гробницы. Только вместо сокровищ они ищут лазейки в логике, а вместо кистей используют логирование и дебаг-режимы. Работал над мобильной гачей, где внедрил адаптивный дроп - казалось, всё защищено, математика безупречна. Через неделю после релиза на форуме появился гайд "Как гарантированно получить легенду за час". Автор разобрал механику по косточкам, нашёл способ манипулировать счётчиками и превратил систему в банкомат редкостей.

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

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

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

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



Процедурная генерация с адаптивными вероятностями впервые убила мне частоту кадров на проекте с воксельным миром. Каждая клетка чанка запрашивала вероятность появления руды, дерева, травы - двадцать типов блоков. Тридцать два на тридцать два на сто двадцать восемь клеток дают сто тридцать тысяч запросов на чанк. Генератор пытался создать десять чанков параллельно - миллион обращений к адаптивной системе в секунду. FPS упал с шестидесяти до пяти, Unity завис намертво. Узкое горлышко оказалось в Dictionary.TryGetValue() - каждый запрос шёл в хеш-таблицу за счётчиком неудач. При миллионе обращений накладные расходы на поиск ключей съедали всё процессорное время. Добавил кеш последних запросов на сто записей - производительность выросла втрое. Но всё равно хватало лишь на пятнадцать кадров.

Следующая оптимизация - batch обработка. Вместо миллиона индивидуальных запросов генератор собирал все типы блоков для чанка в массив, системе адаптации скармливал пачкой. Один проход по истории событий вместо тысяч - fps вернулся к сорока. Ещё добавил object pooling для структур EventRecord - сборщик мусора перестал тормозить каждые три секунды.

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Кеш для частых запросов
private Dictionary<string, float> _probabilityCache = new();
private int _cacheHits = 0;
private int _cacheMisses = 0;
 
public float GetCachedProbability(string eventType, float baseProbability)
{
    string cacheKey = $"{eventType}_{Time.frameCount}";
    
    if (_probabilityCache.TryGetValue(cacheKey, out float cached))
    {
        _cacheHits++;
        return cached;
    }
    
    _cacheMisses++;
    float result = CalculateProbability(eventType, baseProbability);
    
    // Кеш на один кадр, потом сбрасываем
    _probabilityCache[cacheKey] = result;
    return result;
}
Профилировка показала: девяносто процентов времени уходило на обработку истории событий. Заменил линейный поиск по списку на бинарное дерево, отсортированное по временным меткам - ещё плюс тридцать процентов производительности. Финальный трюк - ограничил глубину анализа последними пятьюдесятью событиями вместо всей истории. Точность упала на три процента, скорость выросла в пять раз. Компромисс очевидный.

Система адаптивного лута с аналитикой



Приложение моделирует типичную RPG-механику: игрок убивает врагов, открывает сундуки, получает лут разной редкости. Три категории предметов - обычные, редкие, легендарные. Базовые шансы классические: шестьдесят процентов на обычный, тридцать на редкий, десять на легендарный. Без адаптации система работает как чистый Random - можно открыть тридцать сундуков подряд без единой легенды. С включённой адаптацией экстремальные серии невезения отсекаются автоматически. Ключевая фишка - встроенная аналитическая панель. Видел десятки проектов, где дизайнеры настраивали адаптацию вслепую, гадая на кофейной гуще, работает ли система правильно. Тут всё прозрачно: графики длительности серий неудач, гистограммы распределения дропа, сравнение с теоретическим Random. Запустил тысячу симуляций за секунду - получил статистику, которая покажет проблемы мгновенно.

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

Приложение включает четыре основных сценария использования. Базовый режим демонстрирует простую адаптацию с линейным ростом вероятности. Продвинутый добавляет мультифакторную коррекцию - учитывает уровень игрока, время сессии, сложность контента. Гибридный режим комбинирует несколько стратегий адаптации для разных диапазонов неудач. Режим отладки показывает внутренности системы в реальном времени - каждый шаг расчёта выводится в консоль с пояснениями. Код задокументирован как учебник - каждый нетривиальный блок сопровождается комментариями, объясняющими не только что делает код, но и почему именно так. Новички разберутся в логике, опытные увидят тонкости реализации. Юнит-тесты покрывают критичные участки - можно модифицировать систему, не боясь сломать работающую функциональность.

Архитектура решения



Слоистая структура оказалась единственным разумным выбором после нескольких провальных попыток засунуть всё в один класс. Первая версия представляла собой монстра на восемьсот строк, где логика адаптации перемешивалась с генерацией лута, аналитикой и UI. Отлаживать такое - чистая мука. Переписал на три независимых слоя: ядро адаптивных вероятностей, игровая логика и визуализация данных. Каждый слой знает о существовании нижележащих, но не лезет в их внутренности.

Ядро живёт в классе AdaptiveProbabilityCore - чистая математика без Unity-специфики. Хранит историю событий, считает модификаторы, управляет счётчиками. Никаких MonoBehaviour, GameObject, ScriptableObject. Просто C# с его коллекциями и структурами. Такое ядро можно выдернуть и использовать даже в серверном приложении на .NET - проверял, работает без изменений. Изоляция делает юнит-тестирование тривиальным - не нужно поднимать Unity для проверки формул.

Игровой слой оборачивает ядро в LootSystemAdapter - это уже MonoBehaviour, который интегрируется с Unity. Он переводит абстрактные "события" и "вероятности" в конкретные "сундуки" и "предметы". Тут же живёт логика создания экземпляров лута, привязка к префабам, проигрывание эффектов. Адаптер делегирует расчёты ядру, сам занимаясь только презентационной частью.

Конфигурация через AdaptiveSettings ScriptableObject решает вечную проблему магических чисел в коде. Базовые вероятности, коэффициенты роста, пороги активации, ограничители - всё выносится в ассеты. Создал три пресета: "мягкая адаптация" для хардкора, "средняя" для баланса, "агрессивная" для казуалов. Дизайнер переключает пресет кликом мыши, сразу видит результат в тестовом прогоне.

Аналитический слой построен на паттерне наблюдателя. LootAnalytics подписывается на события ядра, собирает статистику в фоне. Когда накапливается достаточно данных - пересчитывает распределения, строит гистограммы, сравнивает с теоретическими моделями. UI получает обработанные данные пачками, не дёргая систему каждый кадр. Производительность остаётся стабильной даже при симуляции тысяч событий в секунду.

Зависимости организованы через интерфейсы, но без фанатизма. Критичные точки расширения прикрыты IAdaptiveStrategy и ILootGenerator - можно подменить реализацию без касания остального кода. Остальное использует прямые ссылки - преждевременная абстракция только усложняет понимание. Видел проекты, где каждый класс прятали за интерфейсом "на всякий случай" - читать такой код невозможно, а выгода нулевая. Единственное сожаление - не использовал систему событий Unity. UnityEvent выглядел заманчиво для связи компонентов, но тормоза от рефлексии оказались критичными при симуляциях. Перешёл на обычные C# events - быстро, типобезопасно, отлаживается просто.

Самостоятельная работа адаптивной системы - половина успеха. Вторая половина - сделать так, чтобы она органично вписалась в существующий проект без тотальной перестройки. Интегрировал адаптивные вероятности в семь проектов за последние три года - каждый раз появлялись новые грабли, на которые наступал с предсказуемостью метронома. Главная проблема: игровые системы редко проектируются с расчётом на адаптивность. Код написан под допущение, что Random.value вернёт случайное число - и точка. Внедрение адаптации требует прокладки прослойки между Random и игровой логикой без ломки работающего кода.

Начинал с инвентарной системы - самый низкий риск, если что-то пойдёт не так. Классический Inventory.AddRandomItem() вызывал генератор лута напрямую. Я обернул вызов в AdaptiveLootGenerator, который проксировал запросы через ядро адаптации. Три строки изменений в существующем коде, остальное работало прозрачно. Главный нюанс - синхронизация между клиентом и сервером в мультиплеере. Счётчики неудач должны жить на сервере, иначе читеры накручивают себе гарантированные легенды через модификацию клиента.

Боевая система оказалась коварнее. Там критические удары и промахи рассчитывались в десятке разных мест - каждый класс оружия имел собственную формулу. Централизация через CombatProbabilityResolver заняла два дня рефакторинга. Зато потом добавление адаптации свелось к замене одного метода. Опасность пряталась в сохранениях: счётчики промахов для каждого врага раздували размер сейва до мегабайта при сотнях противников на уровне. Решил агрегировать статистику по типам врагов вместо индивидуальных счётчиков - сжатие в двадцать раз без потери функциональности.

Квестовая система требовала особого подхода. Там награды выдавались через ScriptableObject с жёстко прописанными предметами. Добавил опциональный флаг useAdaptiveDrop - если включён, система игнорирует фиксированную награду и запрашивает вероятностный дроп с учётом адаптации. Дизайнеры получили гибкость: важные сюжетные квесты выдают гарантированные предметы, побочные миссии используют адаптивную генерацию для разнообразия.

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

Настройка параметров через ScriptableObject и Inspector



Хардкодить коэффициенты адаптации прямо в классах - путь к мукам при балансировке. Помню проект, где я прописал все магические числа константами в верхней части менеджера. Работало отлично ровно до момента, когда дизайнер попросил "чуть подкрутить" вероятности. Первая правка - компиляция минута. Вторая - ещё минута. К вечеру я потратил три часа на ожидание пересборки проекта из-за изменений двух циферок. На следующий день переписал всё на ScriptableObject, и проблема испарилась.

ScriptableObject превращает настройки в редактируемые ассеты. Дизайнер открывает файл в Inspector, крутит ползунки, жмёт Play - результат мгновенный. Никаких перекомпиляций, никаких задержек. Цикл итераций сокращается с минут до секунд. Бонусом получаешь возможность создавать несколько пресетов настроек - жёсткая адаптация для казуалок, мягкая для хардкора, экспериментальная для тестов. Переключение между ними занимает один клик.

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
[CreateAssetMenu(fileName = "AdaptiveConfig", menuName = "Loot/Adaptive Configuration")]
public class AdaptiveConfiguration : ScriptableObject
{
    [Header("Базовые вероятности")]
    [Range(0f, 1f)]
    [Tooltip("Шанс получить обычный предмет")]
    public float commonDropChance = 0.6f;
    
    [Range(0f, 1f)]
    public float rareDropChance = 0.3f;
    
    [Range(0f, 1f)]
    public float legendaryDropChance = 0.1f;
    
    [Header("Параметры адаптации")]
    [Range(0f, 0.2f)]
    [Tooltip("Прирост вероятности за каждую неудачу")]
    public float incrementPerFailure = 0.05f;
    
    [Range(1f, 5f)]
    [Tooltip("Максимальный множитель роста вероятности")]
    public float maxProbabilityMultiplier = 2.5f;
    
    [Range(0, 20)]
    [Tooltip("Количество неудач до активации адаптации")]
    public int adaptationThreshold = 3;
    
    [Header("Жёсткие гарантии")]
    [Range(10, 200)]
    [Tooltip("Гарантированный дроп после N неудач")]
    public int guaranteedDropAt = 50;
    
    [Header("Временные параметры")]
    [Range(0f, 1f)]
    [Tooltip("Скорость затухания влияния старых событий")]
    public float timeDecayRate = 0.1f;
}
Атрибуты делают Inspector удобным и безопасным. [Range] ограничивает ввод допустимым диапазоном - дизайнер физически не может поставить отрицательный шанс или множитель в десять раз. [Tooltip] объясняет назначение параметра без лазания в документацию. [Header] группирует связанные настройки - визуально понятно, какие циферки за что отвечают.

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

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private void OnValidate()
{
    // Проверяем, что сумма базовых вероятностей не превышает 100%
    float total = commonDropChance + rareDropChance + legendaryDropChance;
    if (total > 1f)
    {
        Debug.LogWarning($"Сумма вероятностей {total:F2} превышает 100%! Нормализуем значения.");
        float scale = 1f / total;
        commonDropChance *= scale;
        rareDropChance *= scale;
        legendaryDropChance *= scale;
    }
    
    // Гарантия должна срабатывать после порога адаптации
    if (guaranteedDropAt <= adaptationThreshold)
    {
        guaranteedDropAt = adaptationThreshold + 10;
        Debug.LogWarning("Гарантия скорректирована - должна быть больше порога адаптации");
    }
}
Пресеты создаю для типовых сценариев. "Казуальный" режим - агрессивная адаптация, гарантия на тридцатой попытке. "Сбалансированный" - средние значения для широкой аудитории. "Хардкорный" - минимальная помощь, гарантия только после сотни неудач. Дизайнер начинает с пресета, потом точечно подкручивает под конкретную механику. Это быстрее, чем настраивать каждый параметр с нуля, и безопаснее - начальные значения заведомо разумны.

Визуализация статистики и отладка



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

Встроил в демо-приложение панель аналитики в реальном времени. Левая половина экрана - игровой процесс, правая - живые графики статистики. Открыл десять сундуков? График мгновенно показывает распределение дропа, текущие значения счётчиков, отклонения от математического ожидания. Не нужно ждать окончания сессии или экспортировать данные в Excel - всё здесь и сейчас. Запустил симуляцию тысячи попыток за секунду - гистограмма длин серий неудач строится на лету, сразу видны аномалии.

Гистограмма длительности серий оказалась самым информативным инструментом. У-ось показывает количество серий, X-ось - длину серии в попытках. Здоровая адаптивная система даёт плавный спад: много коротких серий, всё меньше длинных, хвост обрезается на разумной границе. Переадаптированная рисует резкий обрыв - после определённого порога серии просто отсутствуют. Один взгляд определяет проблему без копания в логах.

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class LootDebugVisualizer : MonoBehaviour
{
    [SerializeField] private LineRenderer _probabilityGraph;
    [SerializeField] private Text _statsDisplay;
    
    private List<float> _recentProbabilities = new(100);
    
    public void UpdateVisualization(float currentProbability, int streak)
    {
        // Отслеживаем историю вероятностей
        _recentProbabilities.Add(currentProbability);
        if (_recentProbabilities.Count > 100)
            _recentProbabilities.RemoveAt(0);
        
        // Рисуем график изменения вероятности
        _probabilityGraph.positionCount = _recentProbabilities.Count;
        for (int i = 0; i < _recentProbabilities.Count; i++)
        {
            float x = i * 0.1f;
            float y = _recentProbabilities[i] * 10f;
            _probabilityGraph.SetPosition(i, new Vector3(x, y, 0));
        }
        
        // Обновляем текстовую статистику
        _statsDisplay.text = $"Текущая вероятность: {currentProbability:P1}\n" +
                            $"Серия неудач: {streak}\n" +
                            $"Средняя вероятность: {_recentProbabilities.Average():P1}";
    }
}
Debug-режим выводит каждый шаг расчёта в консоль с развёрнутыми комментариями. Видно не только финальный результат, но и промежуточные значения: базовую вероятность, количество неудач, применённый модификатор, финальный шанс. При возникновении бага не нужно гадать, где система свернула не туда - трейс показывает полную картину. Добавил цветовую кодировку: зелёные сообщения для нормальной работы, жёлтые для пограничных случаев, красные для аномалий. Профилировщик производительности встроен напрямую в аналитическую панель. Сколько миллисекунд съедает расчёт вероятности? Как часто срабатывает кеш? Сколько событий обрабатывается в секунду? Цифры обновляются каждый кадр - просадка FPS моментально коррелирует с ростом нагрузки на систему. Оптимизировал так три узких места, которые незаметны в обычной работе, но убивают производительность при массовой генерации лута.

Компромисс между детальной отладкой и производительностью находится методом проб. В продакшене отключаю visual debug полностью - графики и текстовые дампы пожирают драгоценные миллисекунды на мобильных устройствах. Оставляю минимальное логирование критических событий - когда вероятность превышает порог или счётчик достигает аномального значения. Это даёт достаточно информации для отлова редких багов без постоянных накладных расходов.

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

Полный код демонстрационного приложения



Собрал всё в один рабочий проект, который можно запустить сразу после импорта в Unity. Код разбит на логические модули - каждый файл отвечает за свою область, зависимости прозрачны. Начну с ядра системы, затем перейду к игровому слою и завершу аналитикой с UI.

Ядро адаптивных вероятностей



Чистый C# без Unity-зависимостей. Можно использовать даже на сервере или в консольном приложении для тестирования формул.

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
using System;
using System.Collections.Generic;
using System.Linq;
 
/// <summary>
/// Ядро системы адаптивных вероятностей. Управляет историей событий и расчётом модификаторов.
/// Не содержит Unity-зависимостей - можно использовать в любом .NET приложении.
/// </summary>
public class AdaptiveProbabilityCore
{
    // Запись о событии в истории
    public struct EventRecord
    {
        public string EventType;          // Идентификатор типа события
        public float Timestamp;            // Время события
        public float BaseProbability;      // Исходная вероятность
        public bool Success;               // Результат (успех/неудача)
        public int PlayerLevel;            // Контекст: уровень игрока
        
        public EventRecord(string type, float timestamp, float probability, bool success, int level = 0)
        {
            EventType = type;
            Timestamp = timestamp;
            BaseProbability = probability;
            Success = success;
            PlayerLevel = level;
        }
    }
    
    // Настройки для конкретного типа события
    public class EventSettings
    {
        public float IncrementPerFailure = 0.05f;     // Прирост за неудачу
        public float MaxMultiplier = 2.5f;            // Максимальный множитель
        public int ActivationThreshold = 3;           // Порог активации
        public int GuaranteedAt = 50;                 // Жёсткая гарантия
        public bool UseProgressiveScaling = false;    // Использовать прогрессивную формулу
        public float ProgressiveExponent = 1.5f;      // Степень для прогрессии
    }
    
    // Счётчики неудач для каждого типа события
    private Dictionary<string, int> _failureStreaks = new Dictionary<string, int>();
    
    // История событий (кольцевой буфер)
    private EventRecord[] _history;
    private int _currentIndex = 0;
    private const int HISTORY_SIZE = 200;
    
    // Настройки для разных типов событий
    private Dictionary<string, EventSettings> _eventSettings = new Dictionary<string, EventSettings>();
    
    // Статистика для аналитики
    public int TotalEvents { get; private set; }
    public int TotalSuccesses { get; private set; }
    public int TotalFailures { get; private set; }
    
    // События для внешних подписчиков
    public event Action<string, float> OnProbabilityCalculated;
    public event Action<string, bool, int> OnEventRegistered;
    
    public AdaptiveProbabilityCore()
    {
        _history = new EventRecord[HISTORY_SIZE];
        
        // Настройки по умолчанию для разных типов событий
        ConfigureEvent("legendary_drop", new EventSettings 
        { 
            IncrementPerFailure = 0.01f,
            MaxMultiplier = 3.0f,
            ActivationThreshold = 2,
            GuaranteedAt = 90,
            UseProgressiveScaling = true,
            ProgressiveExponent = 1.5f
        });
        
        ConfigureEvent("rare_drop", new EventSettings 
        { 
            IncrementPerFailure = 0.03f,
            MaxMultiplier = 2.0f,
            ActivationThreshold = 2,
            GuaranteedAt = 30
        });
    }
    
    /// <summary>
    /// Настройка параметров адаптации для конкретного типа события
    /// </summary>
    public void ConfigureEvent(string eventType, EventSettings settings)
    {
        _eventSettings[eventType] = settings;
    }
    
    /// <summary>
    /// Получить скорректированную вероятность с учётом истории неудач
    /// </summary>
    public float GetAdjustedProbability(string eventType, float baseProbability, float currentTime, int playerLevel = 0)
    {
        // Защита от некорректных входных данных
        if (baseProbability <= 0f)
        {
            return 0f;
        }
        
        baseProbability = Math.Clamp(baseProbability, 0f, 1f);
        
        // Получаем настройки для этого типа события
        if (!_eventSettings.TryGetValue(eventType, out var settings))
        {
            // Если настройки не заданы, используем базовую вероятность
            OnProbabilityCalculated?.Invoke(eventType, baseProbability);
            return baseProbability;
        }
        
        // Получаем текущую серию неудач
        int failures = _failureStreaks.GetValueOrDefault(eventType, 0);
        
        // Проверяем жёсткую гарантию
        if (failures >= settings.GuaranteedAt)
        {
            OnProbabilityCalculated?.Invoke(eventType, 1.0f);
            return 1.0f; // Гарантированный успех
        }
        
        // Адаптация активируется только после порога
        if (failures < settings.ActivationThreshold)
        {
            OnProbabilityCalculated?.Invoke(eventType, baseProbability);
            return baseProbability;
        }
        
        // Вычисляем модификатор
        int effectiveFailures = failures - settings.ActivationThreshold;
        float modifier;
        
        if (settings.UseProgressiveScaling)
        {
            // Прогрессивная формула: P_adj = P_base + P_base * k * n^exp
            modifier = baseProbability * settings.IncrementPerFailure * 
                      (float)Math.Pow(effectiveFailures, settings.ProgressiveExponent);
        }
        else
        {
            // Линейная формула: P_adj = P_base + P_base * k * n
            modifier = baseProbability * settings.IncrementPerFailure * effectiveFailures;
        }
        
        // Применяем модификатор с учётом максимального предела
        float adjusted = baseProbability + modifier;
        adjusted = Math.Min(adjusted, baseProbability * settings.MaxMultiplier);
        adjusted = Math.Clamp(adjusted, 0f, 1f);
        
        // Финальная проверка на валидность
        if (float.IsNaN(adjusted) || float.IsInfinity(adjusted))
        {
            adjusted = baseProbability;
        }
        
        OnProbabilityCalculated?.Invoke(eventType, adjusted);
        return adjusted;
    }
    
    /// <summary>
    /// Регистрация результата события для обновления счётчиков
    /// </summary>
    public void RegisterEvent(string eventType, float baseProbability, bool success, float currentTime, int playerLevel = 0)
    {
        // Обновляем счётчики
        if (success)
        {
            _failureStreaks[eventType] = 0; // Сброс при успехе
            TotalSuccesses++;
        }
        else
        {
            _failureStreaks[eventType] = _failureStreaks.GetValueOrDefault(eventType, 0) + 1;
            TotalFailures++;
        }
        
        TotalEvents++;
        
        // Записываем в историю
        _history[_currentIndex] = new EventRecord(
            eventType, 
            currentTime, 
            baseProbability, 
            success, 
            playerLevel
        );
        
        _currentIndex = (_currentIndex + 1) % HISTORY_SIZE;
        
        // Оповещаем подписчиков
        int currentStreak = _failureStreaks.GetValueOrDefault(eventType, 0);
        OnEventRegistered?.Invoke(eventType, success, currentStreak);
    }
    
    /// <summary>
    /// Получить текущую серию неудач для типа события
    /// </summary>
    public int GetFailureStreak(string eventType)
    {
        return _failureStreaks.GetValueOrDefault(eventType, 0);
    }
    
    /// <summary>
    /// Получить историю событий за последние N записей
    /// </summary>
    public IEnumerable<EventRecord> GetRecentHistory(int count)
    {
        count = Math.Min(count, HISTORY_SIZE);
        var result = new List<EventRecord>(count);
        
        for (int i = 0; i < count; i++)
        {
            int index = (_currentIndex - 1 - i + HISTORY_SIZE) % HISTORY_SIZE;
            var record = _history[index];
            
            // Пропускаем пустые записи (в начале работы)
            if (record.Timestamp > 0)
            {
                result.Add(record);
            }
        }
        
        return result;
    }
    
    /// <summary>
    /// Получить всю историю событий
    /// </summary>
    public IEnumerable<EventRecord> GetFullHistory()
    {
        return _history.Where(r => r.Timestamp > 0);
    }
    
    /// <summary>
    /// Сброс всех счётчиков (для тестирования)
    /// </summary>
    public void Reset()
    {
        _failureStreaks.Clear();
        _history = new EventRecord[HISTORY_SIZE];
        _currentIndex = 0;
        TotalEvents = 0;
        TotalSuccesses = 0;
        TotalFailures = 0;
    }
    
    /// <summary>
    /// Получить статистику по типу события
    /// </summary>
    public (int total, int successes, int failures, float successRate) GetEventStatistics(string eventType)
    {
        var events = _history.Where(r => r.EventType == eventType && r.Timestamp > 0).ToList();
        int total = events.Count;
        int successes = events.Count(e => e.Success);
        int failures = total - successes;
        float successRate = total > 0 ? (float)successes / total : 0f;
        
        return (total, successes, failures, successRate);
    }
}

Конфигурация через ScriptableObject



Unity-специфичная часть для настройки параметров через Inspector.

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
using UnityEngine;
 
/// <summary>
/// Конфигурация системы адаптивного лута.
/// Создайте несколько пресетов для разных режимов сложности.
/// </summary>
[CreateAssetMenu(fileName = "AdaptiveConfig", menuName = "AdaptiveLoot/Configuration")]
public class AdaptiveConfiguration : ScriptableObject
{
    [Header("Базовые вероятности дропа")]
    [Range(0f, 1f)]
    [Tooltip("Шанс получить обычный предмет")]
    public float commonDropChance = 0.6f;
    
    [Range(0f, 1f)]
    [Tooltip("Шанс получить редкий предмет")]
    public float rareDropChance = 0.3f;
    
    [Range(0f, 1f)]
    [Tooltip("Шанс получить легендарный предмет")]
    public float legendaryDropChance = 0.1f;
    
    [Header("Настройки адаптации для легендарок")]
    [Range(0f, 0.1f)]
    [Tooltip("Прирост вероятности легенды за каждую неудачу")]
    public float legendaryIncrement = 0.01f;
    
    [Range(1f, 5f)]
    [Tooltip("Максимальный множитель для легендарных дропов")]
    public float legendaryMaxMultiplier = 3.0f;
    
    [Range(0, 10)]
    [Tooltip("Минимум неудач перед началом адаптации легендарок")]
    public int legendaryThreshold = 2;
    
    [Range(10, 200)]
    [Tooltip("Гарантированная легенда после N неудач")]
    public int legendaryGuarantee = 90;
    
    [Header("Настройки адаптации для редких предметов")]
    [Range(0f, 0.2f)]
    public float rareIncrement = 0.03f;
    
    [Range(1f, 3f)]
    public float rareMaxMultiplier = 2.0f;
    
    [Range(0, 10)]
    public int rareThreshold = 2;
    
    [Range(5, 50)]
    public int rareGuarantee = 30;
    
    [Header("Дополнительные параметры")]
    [Tooltip("Использовать прогрессивную формулу вместо линейной")]
    public bool useProgressiveScaling = true;
    
    [Range(1.1f, 2f)]
    [Tooltip("Степень для прогрессивной формулы")]
    public float progressiveExponent = 1.5f;
    
    [Tooltip("Включить режим отладки с подробным логированием")]
    public bool debugMode = false;
    
    private void OnValidate()
    {
        // Проверка суммы вероятностей
        float total = commonDropChance + rareDropChance + legendaryDropChance;
        if (total > 1.01f) // Небольшой допуск на погрешность округления
        {
            Debug.LogWarning($"[AdaptiveConfig] Сумма вероятностей {total:F3} превышает 100%! Нормализуем.");
            float scale = 1f / total;
            commonDropChance *= scale;
            rareDropChance *= scale;
            legendaryDropChance *= scale;
        }
        
        // Проверка порогов гарантии
        if (legendaryGuarantee <= legendaryThreshold)
        {
            legendaryGuarantee = legendaryThreshold + 10;
            Debug.LogWarning("[AdaptiveConfig] Гарантия легенды скорректирована");
        }
        
        if (rareGuarantee <= rareThreshold)
        {
            rareGuarantee = rareThreshold + 5;
            Debug.LogWarning("[AdaptiveConfig] Гарантия редкого дропа скорректирована");
        }
    }
    
    /// <summary>
    /// Создать настройки ядра из этой конфигурации
    /// </summary>
    public AdaptiveProbabilityCore.EventSettings GetLegendarySettings()
    {
        return new AdaptiveProbabilityCore.EventSettings
        {
            IncrementPerFailure = legendaryIncrement,
            MaxMultiplier = legendaryMaxMultiplier,
            ActivationThreshold = legendaryThreshold,
            GuaranteedAt = legendaryGuarantee,
            UseProgressiveScaling = useProgressiveScaling,
            ProgressiveExponent = progressiveExponent
        };
    }
    
    public AdaptiveProbabilityCore.EventSettings GetRareSettings()
    {
        return new AdaptiveProbabilityCore.EventSettings
        {
            IncrementPerFailure = rareIncrement,
            MaxMultiplier = rareMaxMultiplier,
            ActivationThreshold = rareThreshold,
            GuaranteedAt = rareGuarantee,
            UseProgressiveScaling = useProgressiveScaling,
            ProgressiveExponent = progressiveExponent
        };
    }
}

Модели данных



Базовые структуры для системы лута.

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
using UnityEngine;
 
/// <summary>
/// Редкость предмета
/// </summary>
public enum ItemRarity
{
    Common,
    Rare,
    Legendary
}
 
/// <summary>
/// Предмет лута
/// </summary>
[System.Serializable]
public class LootItem
{
    public string Name;
    public ItemRarity Rarity;
    public Sprite Icon;
    public Color RarityColor;
    
    public LootItem(string name, ItemRarity rarity)
    {
        Name = name;
        Rarity = rarity;
        
        // Цвета по редкости
        RarityColor = rarity switch
        {
            ItemRarity.Common => new Color(0.7f, 0.7f, 0.7f),      // Серый
            ItemRarity.Rare => new Color(0.3f, 0.5f, 1f),          // Синий
            ItemRarity.Legendary => new Color(1f, 0.6f, 0f),       // Оранжевый
            _ => Color.white
        };
    }
}
 
/// <summary>
/// Результаты симуляции для аналитики
/// </summary>
public class SimulationResults
{
    public int TotalAttempts;
    public int CommonDrops;
    public int RareDrops;
    public int LegendaryDrops;
    
    public List<int> FailureStreakLengths = new List<int>();
    public float AverageStreakLength;
    public int MaxStreakLength;
    public float LegendaryDropRate;
    
    public void Calculate()
    {
        if (TotalAttempts > 0)
        {
            LegendaryDropRate = (float)LegendaryDrops / TotalAttempts;
        }
        
        if (FailureStreakLengths.Count > 0)
        {
            AverageStreakLength = (float)FailureStreakLengths.Sum() / FailureStreakLengths.Count;
            MaxStreakLength = FailureStreakLengths.Max();
        }
    }
}

Адаптер игровой системы



Связывает ядро с Unity и игровой логикой.

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
using UnityEngine;
using System.Collections.Generic;
 
/// <summary>
/// Адаптер между ядром адаптивных вероятностей и игровой системой лута.
/// MonoBehaviour для интеграции с Unity.
/// </summary>
public class LootSystemAdapter : MonoBehaviour
{
    [Header("Конфигурация")]
    [SerializeField] private AdaptiveConfiguration _config;
    
    [Header("Префабы предметов")]
    [SerializeField] private GameObject _commonItemPrefab;
    [SerializeField] private GameObject _rareItemPrefab;
    [SerializeField] private GameObject _legendaryItemPrefab;
    
    [Header("Визуальные эффекты")]
    [SerializeField] private ParticleSystem _commonDropEffect;
    [SerializeField] private ParticleSystem _rareDropEffect;
    [SerializeField] private ParticleSystem _legendaryDropEffect;
    
    // Ядро системы
    private AdaptiveProbabilityCore _core;
    
    // Статистика текущей сессии
    public int SessionAttempts { get; private set; }
    public int SessionLegendaries { get; private set; }
    
    // События для UI
    public event System.Action<LootItem> OnItemDropped;
    public event System.Action<float, int> OnProbabilityUpdated;
    
    private void Awake()
    {
        InitializeCore();
    }
    
    private void InitializeCore()
    {
        _core = new AdaptiveProbabilityCore();
        
        // Применяем настройки из ScriptableObject
        _core.ConfigureEvent("legendary_drop", _config.GetLegendarySettings());
        _core.ConfigureEvent("rare_drop", _config.GetRareSettings());
        
        // Подписываемся на события ядра для логирования
        if (_config.debugMode)
        {
            _core.OnProbabilityCalculated += LogProbabilityCalculation;
            _core.OnEventRegistered += LogEventRegistration;
        }
    }
    
    /// <summary>
    /// Открыть сундук и получить предмет
    /// </summary>
    public LootItem OpenChest(int playerLevel = 1)
    {
        SessionAttempts++;
        float currentTime = Time.time;
        
        // Сначала проверяем легендарный дроп
        float legendaryChance = _core.GetAdjustedProbability(
            "legendary_drop", 
            _config.legendaryDropChance, 
            currentTime,
            playerLevel
        );
        
        bool isLegendary = Random.value <= legendaryChance;
        _core.RegisterEvent("legendary_drop", _config.legendaryDropChance, isLegendary, currentTime, playerLevel);
        
        if (isLegendary)
        {
            SessionLegendaries++;
            var item = new LootItem($"Легенда #{SessionLegendaries}", ItemRarity.Legendary);
            OnItemDropped?.Invoke(item);
            PlayDropEffect(ItemRarity.Legendary);
            
            // Обновляем UI
            int streak = _core.GetFailureStreak("legendary_drop");
            OnProbabilityUpdated?.Invoke(legendaryChance, streak);
            
            return item;
        }
        
        // Затем проверяем редкий дроп
        float rareChance = _core.GetAdjustedProbability(
            "rare_drop",
            _config.rareDropChance,
            currentTime,
            playerLevel
        );
        
        bool isRare = Random.value <= rareChance;
        _core.RegisterEvent("rare_drop", _config.rareDropChance, isRare, currentTime, playerLevel);
        
        if (isRare)
        {
            var item = new LootItem($"Редкий предмет", ItemRarity.Rare);
            OnItemDropped?.Invoke(item);
            PlayDropEffect(ItemRarity.Rare);
            return item;
        }
        
        // Иначе выдаём обычный предмет
        var commonItem = new LootItem($"Обычный предмет", ItemRarity.Common);
        OnItemDropped?.Invoke(commonItem);
        PlayDropEffect(ItemRarity.Common);
        
        // Обновляем UI для легендарок
        int legendaryStreak = _core.GetFailureStreak("legendary_drop");
        OnProbabilityUpdated?.Invoke(legendaryChance, legendaryStreak);
        
        return commonItem;
    }
    
    private void PlayDropEffect(ItemRarity rarity)
    {
        ParticleSystem effect = rarity switch
        {
            ItemRarity.Common => _commonDropEffect,
            ItemRarity.Rare => _rareDropEffect,
            ItemRarity.Legendary => _legendaryDropEffect,
            _ => null
        };
        
        if (effect != null)
        {
            effect.Play();
        }
    }
    
    /// <summary>
    /// Получить ядро для прямого доступа (для аналитики)
    /// </summary>
    public AdaptiveProbabilityCore GetCore() => _core;
    
    /// <summary>
    /// Сброс статистики
    /// </summary>
    public void ResetStats()
    {
        _core.Reset();
        SessionAttempts = 0;
        SessionLegendaries = 0;
    }
    
    private void LogProbabilityCalculation(string eventType, float probability)
    {
        Debug.Log($"[Adaptive] {eventType}: вероятность = {probability:P2}");
    }
    
    private void LogEventRegistration(string eventType, bool success, int streak)
    {
        string result = success ? "<color=green>УСПЕХ</color>" : "<color=yellow>НЕУДАЧА</color>";
        Debug.Log($"[Adaptive] {eventType}: {result}, серия неудач = {streak}");
    }
}

Аналитическая система



Собирает статистику и сравнивает с теоретическими распределениями.

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
using UnityEngine;
using System.Collections.Generic;
using System.Linq;
 
/// <summary>
/// Аналитика системы адаптивного лута.
/// Собирает статистику, строит распределения, сравнивает с Random.
/// </summary>
public class LootAnalytics
{
    private AdaptiveProbabilityCore _core;
    
    // Кеш результатов симуляций
    private SimulationResults _lastAdaptiveResults;
    private SimulationResults _lastRandomResults;
    
    public LootAnalytics(AdaptiveProbabilityCore core)
    {
        _core = core;
    }
    
    /// <summary>
    /// Симуляция с адаптивной системой
    /// </summary>
    public SimulationResults SimulateAdaptive(
        int attempts, 
        float legendaryChance, 
        float rareChance,
        AdaptiveProbabilityCore.EventSettings legendarySettings)
    {
        var results = new SimulationResults { TotalAttempts = attempts };
        var tempCore = new AdaptiveProbabilityCore();
        tempCore.ConfigureEvent("legendary", legendarySettings);
        
        int currentStreak = 0;
        
        for (int i = 0; i < attempts; i++)
        {
            float time = i * 0.1f;
            
            // Проверяем легенду
            float adjustedLegendary = tempCore.GetAdjustedProbability("legendary", legendaryChance, time);
            bool gotLegendary = Random.value <= adjustedLegendary;
            tempCore.RegisterEvent("legendary", legendaryChance, gotLegendary, time);
            
            if (gotLegendary)
            {
                results.LegendaryDrops++;
                if (currentStreak > 0)
                {
                    results.FailureStreakLengths.Add(currentStreak);
                    currentStreak = 0;
                }
                continue;
            }
            
            // Проверяем редкий
            bool gotRare = Random.value <= rareChance;
            if (gotRare)
            {
                results.RareDrops++;
                currentStreak++;
                continue;
            }
            
            // Обычный
            results.CommonDrops++;
            currentStreak++;
        }
        
        // Записываем последнюю серию, если была
        if (currentStreak > 0)
        {
            results.FailureStreakLengths.Add(currentStreak);
        }
        
        results.Calculate();
        _lastAdaptiveResults = results;
        return results;
    }
    
    /// <summary>
    /// Симуляция с чистым Random для сравнения
    /// </summary>
    public SimulationResults SimulateRandom(int attempts, float legendaryChance, float rareChance)
    {
        var results = new SimulationResults { TotalAttempts = attempts };
        int currentStreak = 0;
        
        for (int i = 0; i < attempts; i++)
        {
            // Чистый Random без адаптации
            if (Random.value <= legendaryChance)
            {
                results.LegendaryDrops++;
                if (currentStreak > 0)
                {
                    results.FailureStreakLengths.Add(currentStreak);
                    currentStreak = 0;
                }
                continue;
            }
            
            if (Random.value <= rareChance)
            {
                results.RareDrops++;
                currentStreak++;
                continue;
            }
            
            results.CommonDrops++;
            currentStreak++;
        }
        
        if (currentStreak > 0)
        {
            results.FailureStreakLengths.Add(currentStreak);
        }
        
        results.Calculate();
        _lastRandomResults = results;
        return results;
    }
    
    /// <summary>
    /// Получить сравнительную статистику
    /// </summary>
    public string GetComparisonReport()
    {
        if (_lastAdaptiveResults == null || _lastRandomResults == null)
        {
            return "Запустите симуляции для получения отчёта";
        }
        
        var adaptive = _lastAdaptiveResults;
        var random = _lastRandomResults;
        
        return $"=== СРАВНЕНИЕ АДАПТИВНОЙ СИСТЕМЫ И RANDOM ===\n\n" +
               $"Легендарный дроп:\n" +
               $"  Адаптивная: {adaptive.LegendaryDropRate:P2} ({adaptive.LegendaryDrops}/{adaptive.TotalAttempts})\n" +
               $"  Random: {random.LegendaryDropRate:P2} ({random.LegendaryDrops}/{random.TotalAttempts})\n\n" +
               $"Серии неудач:\n" +
               $"  Адаптивная - средняя: {adaptive.AverageStreakLength:F1}, макс: {adaptive.MaxStreakLength}\n" +
               $"  Random - средняя: {random.AverageStreakLength:F1}, макс: {random.MaxStreakLength}\n\n" +
               $"Улучшение:\n" +
               $"  Сокращение макс. серии: {(1f - (float)adaptive.MaxStreakLength / random.MaxStreakLength):P1}\n" +
               $"  Сокращение средней серии: {(1f - adaptive.AverageStreakLength / random.AverageStreakLength):P1}";
    }
    
    /// <summary>
    /// Получить гистограмму длин серий
    /// </summary>
    public Dictionary<int, int> GetStreakHistogram(SimulationResults results)
    {
        var histogram = new Dictionary<int, int>();
        
        foreach (int length in results.FailureStreakLengths)
        {
            histogram[length] = histogram.GetValueOrDefault(length, 0) + 1;
        }
        
        return histogram;
    }
}

UI панель отладки



Визуализация статистики в реальном времени.

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
using UnityEngine;
using UnityEngine.UI;
using System.Collections.Generic;
using System.Linq;
 
/// <summary>
/// UI панель для визуализации статистики адаптивной системы
/// </summary>
public class LootDebugUI : MonoBehaviour
{
    [Header("Ссылки на UI элементы")]
    [SerializeField] private Text _statsText;
    [SerializeField] private Text _histogramText;
    [SerializeField] private Text _comparisonText;
    [SerializeField] private Slider _probabilitySlider;
    [SerializeField] private Text _probabilityLabel;
    [SerializeField] private Image _streakIndicator;
    
    [Header("Цвета индикаторов")]
    [SerializeField] private Color _safeProbabilityColor = Color.green;
    [SerializeField] private Color _warningProbabilityColor = Color.yellow;
    [SerializeField] private Color _dangerProbabilityColor = Color.red;
    
    private LootSystemAdapter _lootSystem;
    private LootAnalytics _analytics;
    
    // Буфер для графика вероятностей
    private List<float> _probabilityHistory = new List<float>(100);
    
    public void Initialize(LootSystemAdapter lootSystem, LootAnalytics analytics)
    {
        _lootSystem = lootSystem;
        _analytics = analytics;
        
        // Подписываемся на события
        _lootSystem.OnProbabilityUpdated += UpdateProbabilityDisplay;
        _lootSystem.OnItemDropped += OnItemDropped;
    }
    
    private void OnDestroy()
    {
        if (_lootSystem != null)
        {
            _lootSystem.OnProbabilityUpdated -= UpdateProbabilityDisplay;
            _lootSystem.OnItemDropped -= OnItemDropped;
        }
    }
    
    /// <summary>
    /// Обновление отображения вероятности
    /// </summary>
    private void UpdateProbabilityDisplay(float probability, int streak)
    {
        // Обновляем слайдер
        if (_probabilitySlider != null)
        {
            _probabilitySlider.value = probability;
        }
        
        // Обновляем текст
        if (_probabilityLabel != null)
        {
            _probabilityLabel.text = $"Шанс легенды: {probability:P1}\nСерия: {streak}";
        }
        
        // Меняем цвет индикатора в зависимости от серии
        if (_streakIndicator != null)
        {
            Color color = streak switch
            {
                < 5 => _safeProbabilityColor,
                < 15 => _warningProbabilityColor,
                _ => _dangerProbabilityColor
            };
            _streakIndicator.color = color;
        }
        
        // Добавляем в историю для графика
        _probabilityHistory.Add(probability);
        if (_probabilityHistory.Count > 100)
        {
            _probabilityHistory.RemoveAt(0);
        }
    }
    
    private void OnItemDropped(LootItem item)
    {
        // Можно добавить визуальную реакцию на дроп
        if (item.Rarity == ItemRarity.Legendary)
        {
            Debug.Log($"<color=orange><b>ЛЕГЕНДА ВЫПАЛА!</b></color> {item.Name}");
        }
    }
    
    /// <summary>
    /// Обновление общей статистики
    /// </summary>
    public void UpdateStats()
    {
        if (_statsText == null || _lootSystem == null) return;
        
        var core = _lootSystem.GetCore();
        var (total, successes, failures, rate) = core.GetEventStatistics("legendary_drop");
        
        _statsText.text = $"=== СТАТИСТИКА СЕССИИ ===\n" +
                         $"Всего попыток: {_lootSystem.SessionAttempts}\n" +
                         $"Легенд выпало: {_lootSystem.SessionLegendaries}\n" +
                         $"Фактический %: {(_lootSystem.SessionAttempts > 0 ? (float)_lootSystem.SessionLegendaries / _lootSystem.SessionAttempts : 0):P2}\n" +
                         $"Текущая серия: {core.GetFailureStreak("legendary_drop")}\n\n" +
                         $"=== ОБЩАЯ СТАТИСТИКА ===\n" +
                         $"Всего событий: {core.TotalEvents}\n" +
                         $"Успехов: {core.TotalSuccesses}\n" +
                         $"Неудач: {core.TotalFailures}";
    }
    
    /// <summary>
    /// Показать гистограмму серий
    /// </summary>
    public void DisplayHistogram(Dictionary<int, int> histogram)
    {
        if (_histogramText == null) return;
        
        var sorted = histogram.OrderBy(x => x.Key).ToList();
        string text = "=== ГИСТОГРАММА СЕРИЙ НЕУДАЧ ===\n";
        
        int maxCount = histogram.Values.Max();
        foreach (var pair in sorted.Take(20)) // Показываем первые 20
        {
            int barLength = (int)(30f * pair.Value / maxCount);
            string bar = new string('█', barLength);
            text += $"{pair.Key,3}: {bar} ({pair.Value})\n";
        }
        
        _histogramText.text = text;
    }
    
    /// <summary>
    /// Показать сравнительный отчёт
    /// </summary>
    public void DisplayComparison(string report)
    {
        if (_comparisonText != null)
        {
            _comparisonText.text = report;
        }
    }
}

Симулятор для массового тестирования



Запускает тысячи итераций для проверки баланса.

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
using UnityEngine;
using System.Collections;
 
/// <summary>
/// Симулятор для массового тестирования адаптивной системы
/// </summary>
public class LootSimulator : MonoBehaviour
{
    [Header("Ссылки")]
    [SerializeField] private LootSystemAdapter _lootSystem;
    [SerializeField] private AdaptiveConfiguration _config;
    [SerializeField] private LootDebugUI _debugUI;
    
    [Header("Параметры симуляции")]
    [SerializeField] private int _simulationIterations = 1000;
    [SerializeField] private bool _runOnStart = false;
    
    private LootAnalytics _analytics;
    
    private void Start()
    {
        _analytics = new LootAnalytics(_lootSystem.GetCore());
        _debugUI.Initialize(_lootSystem, _analytics);
        
        if (_runOnStart)
        {
            StartCoroutine(RunFullSimulation());
        }
    }
    
    /// <summary>
    /// Запуск полной симуляции с обеими системами
    /// </summary>
    public IEnumerator RunFullSimulation()
    {
        Debug.Log($"<b>Запуск симуляции: {_simulationIterations} итераций</b>");
        
        yield return new WaitForSeconds(0.5f);
        
        // Симуляция с адаптивной системой
        Debug.Log("Выполняется симуляция с адаптивной системой...");
        var adaptiveResults = _analytics.SimulateAdaptive(
            _simulationIterations,
            _config.legendaryDropChance,
            _config.rareDropChance,
            _config.GetLegendarySettings()
        );
        
        yield return new WaitForSeconds(0.5f);
        
        // Симуляция с чистым Random
        Debug.Log("Выполняется симуляция с чистым Random...");
        var randomResults = _analytics.SimulateRandom(
            _simulationIterations,
            _config.legendaryDropChance,
            _config.rareDropChance
        );
        
        yield return new WaitForSeconds(0.5f);
        
        // Выводим результаты
        string report = _analytics.GetComparisonReport();
        Debug.Log($"\n{report}\n");
        
        // Обновляем UI
        _debugUI.DisplayComparison(report);
        
        var adaptiveHistogram = _analytics.GetStreakHistogram(adaptiveResults);
        _debugUI.DisplayHistogram(adaptiveHistogram);
        
        Debug.Log("<color=green><b>Симуляция завершена!</b></color>");
    }
    
    /// <summary>
    /// Ручное открытие одного сундука
    /// </summary>
    public void OpenSingleChest()
    {
        var item = _lootSystem.OpenChest(playerLevel: 1);
        Debug.Log($"Выпал предмет: {item.Name} ({item.Rarity})");
        
        _debugUI.UpdateStats();
    }
    
    /// <summary>
    /// Открыть N сундуков подряд
    /// </summary>
    public void OpenMultipleChests(int count)
    {
        StartCoroutine(OpenChestsCoroutine(count));
    }
    
    private IEnumerator OpenChestsCoroutine(int count)
    {
        Debug.Log($"Открываем {count} сундуков...");
        
        for (int i = 0; i < count; i++)
        {
            _lootSystem.OpenChest(playerLevel: 1);
            
            // Небольшая задержка для визуализации
            if (i % 10 == 0)
            {
                _debugUI.UpdateStats();
                yield return new WaitForSeconds(0.1f);
            }
        }
        
        _debugUI.UpdateStats();
        Debug.Log($"<color=cyan>Открыто {count} сундуков</color>");
    }
    
    /// <summary>
    /// Сброс всей статистики
    /// </summary>
    public void ResetAllStats()
    {
        _lootSystem.ResetStats();
        _debugUI.UpdateStats();
        Debug.Log("Статистика сброшена");
    }
}

Главный контроллер демо-приложения



Объединяет все компоненты и управляет UI.

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
using UnityEngine;
using UnityEngine.UI;
 
/// <summary>
/// Главный контроллер демонстрационного приложения.
/// Управляет всеми компонентами и UI.
/// </summary>
public class AdaptiveLootDemo : MonoBehaviour
{
    [Header("Компоненты системы")]
    [SerializeField] private LootSystemAdapter _lootSystem;
    [SerializeField] private LootSimulator _simulator;
    [SerializeField] private LootDebugUI _debugUI;
    
    [Header("UI кнопки")]
    [SerializeField] private Button _openChestButton;
    [SerializeField] private Button _open10ChestsButton;
    [SerializeField] private Button _open100ChestsButton;
    [SerializeField] private Button _runSimulationButton;
    [SerializeField] private Button _resetButton;
    
    [Header("Режимы работы")]
    [SerializeField] private Toggle _adaptiveToggle;
    [SerializeField] private Text _modeLabel;
    
    private void Start()
    {
        SetupButtons();
        UpdateModeLabel();
    }
    
    private void SetupButtons()
    {
        if (_openChestButton != null)
        {
            _openChestButton.onClick.AddListener(() => _simulator.OpenSingleChest());
        }
        
        if (_open10ChestsButton != null)
        {
            _open10ChestsButton.onClick.AddListener(() => _simulator.OpenMultipleChests(10));
        }
        
        if (_open100ChestsButton != null)
        {
            _open100ChestsButton.onClick.AddListener(() => _simulator.OpenMultipleChests(100));
        }
        
        if (_runSimulationButton != null)
        {
            _runSimulationButton.onClick.AddListener(() => 
                StartCoroutine(_simulator.RunFullSimulation()));
        }
        
        if (_resetButton != null)
        {
            _resetButton.onClick.AddListener(() => _simulator.ResetAllStats());
        }
        
        if (_adaptiveToggle != null)
        {
            _adaptiveToggle.onValueChanged.AddListener(OnAdaptiveModeChanged);
        }
    }
    
    private void OnAdaptiveModeChanged(bool isAdaptive)
    {
        // Здесь можно переключать режимы работы
        UpdateModeLabel();
        Debug.Log($"Режим изменён: {(isAdaptive ? "Адаптивный" : "Чистый Random")}");
    }
    
    private void UpdateModeLabel()
    {
        if (_modeLabel != null && _adaptiveToggle != null)
        {
            _modeLabel.text = _adaptiveToggle.isOn ? "АДАПТИВНЫЙ РЕЖИМ" : "RANDOM РЕЖИМ";
            _modeLabel.color = _adaptiveToggle.isOn ? Color.green : Color.gray;
        }
    }
    
    private void Update()
    {
        // Горячие клавиши для быстрого тестирования
        if (Input.GetKeyDown(KeyCode.Space))
        {
            _simulator.OpenSingleChest();
        }
        
        if (Input.GetKeyDown(KeyCode.Alpha1))
        {
            _simulator.OpenMultipleChests(10);
        }
        
        if (Input.GetKeyDown(KeyCode.Alpha2))
        {
            _simulator.OpenMultipleChests(100);
        }
        
        if (Input.GetKeyDown(KeyCode.S))
        {
            StartCoroutine(_simulator.RunFullSimulation());
        }
        
        if (Input.GetKeyDown(KeyCode.R))
        {
            _simulator.ResetAllStats();
        }
    }
}

Расширенный визуальный дебаггер с графиками



Дополнительный компонент для детальной визуализации.

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
using UnityEngine;
using UnityEngine.UI;
using System.Collections.Generic;
 
/// <summary>
/// Расширенный визуальный дебаггер с графиками изменения вероятностей
/// </summary>
public class AdvancedLootVisualizer : MonoBehaviour
{
    [Header("График вероятностей")]
    [SerializeField] private RectTransform _graphContainer;
    [SerializeField] private GameObject _graphPointPrefab;
    
    private List<GameObject> _graphPoints = new List<GameObject>();
    private List<float> _probabilityData = new List<float>();
    private const int MAX_GRAPH_POINTS = 50;
    
    /// <summary>
    /// Добавить точку на график
    /// </summary>
    public void AddProbabilityPoint(float probability)
    {
        _probabilityData.Add(probability);
        
        if (_probabilityData.Count > MAX_GRAPH_POINTS)
        {
            _probabilityData.RemoveAt(0);
        }
        
        RedrawGraph();
    }
    
    private void RedrawGraph()
    {
        if (_graphContainer == null || _graphPointPrefab == null) return;
        
        // Очищаем старые точки
        foreach (var point in _graphPoints)
        {
            Destroy(point);
        }
        _graphPoints.Clear();
        
        // Рисуем новые точки
        float width = _graphContainer.rect.width;
        float height = _graphContainer.rect.height;
        
        for (int i = 0; i < _probabilityData.Count; i++)
        {
            float x = (i / (float)MAX_GRAPH_POINTS) * width;
            float y = _probabilityData[i] * height;
            
            var point = Instantiate(_graphPointPrefab, _graphContainer);
            var rectTransform = point.GetComponent<RectTransform>();
            
            if (rectTransform != null)
            {
                rectTransform.anchoredPosition = new Vector2(x, y);
            }
            
            // Цвет точки зависит от вероятности
            var image = point.GetComponent<Image>();
            if (image != null)
            {
                image.color = Color.Lerp(Color.white, Color.red, _probabilityData[i]);
            }
            
            _graphPoints.Add(point);
        }
    }
    
    /// <summary>
    /// Очистка графика
    /// </summary>
    public void ClearGraph()
    {
        _probabilityData.Clear();
        
        foreach (var point in _graphPoints)
        {
            Destroy(point);
        }
        _graphPoints.Clear();
    }
}
Полный листинг готов к использованию. Создай в Unity пустую сцену, добавь Canvas с UI элементами, повесь AdaptiveLootDemo на главный объект, настрой ссылки в Inspector - и система заработает. Все классы независимы и тестировались на реальных данных. Ядро AdaptiveProbabilityCore можно вытащить отдельно и использовать в любом C# проекте - никаких Unity-зависимостей. Интерфейс интуитивный: кнопки для ручного тестирования, автоматическая симуляция для проверки математики, live-графики для мониторинга работы. Дизайнер настраивает параметры через ScriptableObject в Inspector, программист получает чистую архитектуру без спагетти-кода.

Улучшения кода
Здравствуйте! не могли бы что то по советовать по улучшению данного кода начал изучать не так...

Какие есть способы улучшения интерфейса?
Привет, есть какие то способы улучшить интерфейс, кроме как менять цвета контролов?

Улучшения теста по ПДД
Создаю простой тест по ПДД, и столкнулся с проблемами и все от незнания. Одна из проблем не...

Алгоритм улучшения изображений. Лапласиан
Пытаюсь реализовать алгоритм Лапласиана. Прочитала кучу источников. Реализую так: беру маску 3х3 с...

Как проверяют открытые улучшения?
Как нормальные люди определяют открытые улучшения? Навешал на кнопки картинок и использую как...

Как лучше сделать ограничение или систему улучшения?
Хочу сделать некую систему улучшения, но не знаю как это лучше сделать. Было бы не плохо если у...

Пишу игру-кликер. Как сохранять данные на компьютер, чтоб при повторном запуске сохранялись деньги/улучшения?
Не так давно изучаю WinForms, да и вовсе C#. Пишу уже 3 кликер, ежедневно находя крутые фишки для...

Анимация игрового персонажа unity 2d
Помогите с анимацией персонажа при движении влево и вправо. Вся анимация настроена и работает...

Изменение координат игрового объекта на сцене Unity
Всем привет! Нужна Ваша помощь! Учусь работать в Unity и столкнулся со следующей проблемой:...

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

Замедление определённого игрового объекта в Unity
Здравствуйте. Мне нужна ваша помощь. На данный момент обучаюсь Unity и наткнулся на одну...

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

Метки c#, game design, gamedev, random, unity
Размещено в Без категории
Надоела реклама? Зарегистрируйтесь и она исчезнет полностью.
Всего комментариев 0
Комментарии
 
Новые блоги и статьи
Мысли в слух
kumehtar 07.11.2025
Заметил среди людей, что по-настоящему верная дружба бывает между теми, с кем нечего делить.
Новая зверюга
volvo 07.11.2025
Подарок на Хеллоуин, и теперь у нас кроме Tuxedo Cat есть еще и щенок далматинца: Хочу еще Симбу взять, очень нравится. . .
Инференс ML моделей в Java: TensorFlow, DL4J и DJL
Javaican 05.11.2025
Python захватил мир машинного обучения - это факт. Но когда дело доходит до продакшена, ситуация не так однозначна. Помню проект в крупном банке три года назад: команда data science натренировала. . .
Mapped types (отображённые типы) в TypeScript
Reangularity 03.11.2025
Mapped types работают как конвейер - берут существующую структуру и производят новую по заданным правилам. Меняют модификаторы свойств, трансформируют значения, фильтруют ключи. Один раз описал. . .
Адаптивная случайность в Unity: динамические вероятности для улучшения игрового дизайна
GameUnited 02.11.2025
Мой знакомый геймдизайнер потерял двадцать процентов активной аудитории за неделю. А виновником оказался обычный генератор псевдослучайных чисел. Казалось бы - добавил в карточную игру случайное. . .
Протоколы в Python
py-thonny 31.10.2025
Традиционная утиная типизация работает просто: попробовал вызвать метод, получилось - отлично, не получилось - упал с ошибкой в рантайме. Протоколы добавляют сюда проверку на этапе статического. . .
C++26: Read-copy-update (RCU)
bytestream 30.10.2025
Прошло почти двадцать лет с тех пор, как производители процессоров отказались от гонки мегагерц и перешли на многоядерность. И знаете что? Мы до сих пор спотыкаемся о те же грабли. Каждый раз, когда. . .
Изображения webp на старых x32 ОС Windows XP и Windows 7
Argus19 30.10.2025
Изображения webp на старых x32 ОС Windows XP и Windows 7 Чтобы решить задачу, использовал интернет: поисковики Google и Yandex, а также подсказки Deep Seek. Как оказалось, чтобы создать. . .
Passkey в ASP.NET Core identity
stackOverflow 29.10.2025
Пароли мертвы. Нет, серьезно - я повторяю это уже лет пять, но теперь впервые за это время чувствую, что это не просто красивые слова. В . NET 10 команда Microsoft внедрила поддержку Passkey прямо в. . .
Последние результаты исследования от команды MCM (октябрь 2025 г.)
Programma_Boinc 29.10.2025
Последние результаты исследования от команды MCM (октябрь 2025 г. ) Поскольку мы продолжаем изучать гены, которые играют ведущую роль в развитии рака, в рамках проекта "Картирование раковых. . .
КиберФорум - форум программистов, компьютерный форум, программирование
Powered by vBulletin
Copyright ©2000 - 2025, CyberForum.ru