Случайные блуждания и цепи Маркова в геймдизайне

Решаем геймдизайнерские задачки при помощи высшей математики.

Случайные блуждания и цепи Маркова в геймдизайне

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

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

  1. Сколько сундуков откроет игрок, если из сундуков могут выпасть еще сундуки;
  2. Сколько золота уйдет на прокачку меча, если меч может ломаться;
  3. Какая вероятность победить в денежном поединке.

Задача про алхимика

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

Случайные блуждания и цепи Маркова в геймдизайне

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

Случайные блуждания и цепи Маркова в геймдизайне

Обозначим дороги следующим образом: (локация А → локация Б: вероятность перехода из локации А в локацию Б):

  1. Дом → Шахматная поляна: 0.8
  2. Дом → Уборная тролля: 0.2
  3. Шахматная поляна → Дом: 0.5
  4. Шахматная поляна → Уборная тролля: 0.3
  5. Шахматная поляна → Космическая река: 0.2
  6. Уборная тролля → Дом: 0.5
  7. Уборная тролля → Шахматная поляна: 0.2
  8. Уборная тролля → Космическая река: 0.3
  9. Космическая река → Шахматная поляна: 0.5
  10. Космическая река → Космическая река: 0.5

Обратите внимание, что сумма всех вероятностей переходов из каждой локации равна 1. Если сумма вышла меньше 1, то считаем, что разность — это вероятность перехода из А в А. А если больше, определенно что-то пошло не так.

Алхимик отправляется за ингредиентами. Вопрос: какова вероятность, что спустя два перехода он окажется у Космической реки?

Есть два возможных пути до реки, состоящих из двух переходов:

  • Дом → Шахматная поляна → Космическая река: 0.8 ⋅ 0.2 = 0.16
  • Дом → Уборная тролля → Космическая река: 0.2 ⋅ 0.3 = 0.06

Получается, что с вероятностью 0.22 алхимик окажется у реки.

Хорошо, а если мы хотим узнать, с какой вероятностью он будет дома через 10 переходов? А через 100? Получается довольно много расчетов, и тут нам на помощь приходят цепи Маркова.

Цепи Маркова

Введем следующие обозначения:

  • S — конечное множество состояний. В нашем случае это множество локаций: {Дом, Шахматная поляна, Уборная тролля, Космическая река};
  • Xi — состояние, в котором мы находимся после i-ого перехода, XiS. Иными словами, это локация на момент i;
  • Переход — это смена состояния Xi на Xi+1 с заданной вероятностью;
  • Случайный процесс — набор состояний {X0, X1, X2, ..., Xn−1, Xn}, например, {Дом, Шахматная поляна, Космическая река, ..., Дом}.

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

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

Случайные блуждания и цепи Маркова в геймдизайне
Случайные блуждания и цепи Маркова в геймдизайне

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

Случайные блуждания и цепи Маркова в геймдизайне

Таблицу представим как матрицу:

Случайные блуждания и цепи Маркова в геймдизайне

Строго говоря, мы получили матрицу переходов, где:

  1. Строки — настоящее, то есть, состояние Xi;
  2. Столбцы — будущее, то есть, состояние Xi+1;
  3. Элемент (i, j) — вероятность перехода из состояния Xi в Xj.

Более того, сама матрица получилась квадратной и отражает все возможные переходы, поэтому сумма строк равна 1.

Небольшое напоминание:

1. aij — элемент матрицы, где i — номер строки, j — столбца;

2. Можно складывать матрицы только одного размера. При этом складываются элементы с одинаковым индексом;

3. Произведение матриц определено только в том случае, если число столбцов первой матрицы равно числу строк второй. При умножении i-ой строки матрицы A на j-ый столбец B получим cij, а в совокупности они образуют новую матрицу C.

Случайные блуждания и цепи Маркова в геймдизайне

Любая случайная величина (например, Xi) обладает распределением. В нашем случае можно записать его как вектор-строку размерности N, где N — мощность множества S.

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

Подсказка:

Расчеты для этой части статьи можно найти в Google Sheets.

Странствия алхимика

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

Всего у нас четыре локации — следовательно, мощность S = 4. Алхимик начинает путешествие из дома. Отсюда получим распределение случайной величины X0 и обозначим его в виде вектора-строки X0′:

Случайные блуждания и цепи Маркова в геймдизайне

При умножении X0′ на T получим X1′ — вектор-строку распределения X1:

Случайные блуждания и цепи Маркова в геймдизайне

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

Далее при при умножении X1′ на T получим X2′ — вектор-строку распределения X2:

Случайные блуждания и цепи Маркова в геймдизайне

Обратим внимание:

Случайные блуждания и цепи Маркова в геймдизайне

Подсказка:

Матрицы можно умножать в Google Sheets или Excel при помощи = МУМНОЖ().

Таким образом, получим, что распределение на шаг t — это произведение начального распределения и матрицы переходов в степени t:

Случайные блуждания и цепи Маркова в геймдизайне

Здесь первый элемент получившейся вектор-строки — это вероятность оказаться дома спустя t переходов. Задача решена.

Интересный факт:

Предел T^n при n → ∞ даст вероятность быть в состоянии j после бесконечного количества шагов, где j — это j-ый столбец. Это также называется эквилибриумом (steady state). Значение состояния может также трактоваться, как время, которое будет проведено в нем, причем вне зависимости от начального состояния. Время в данном случае измеряется в процентах.

Случайные блуждания и цепи Маркова в геймдизайне

Река не отпускает

На практике нас чаще интересует вероятность наступления какого-то события, чем его распределение. А что касается процессов — то вероятность достижения (hitting probability). Например, вероятность достичь подмножество A множества S.

Для удобства обозначим локации через индексы:

  1. Дом;
  2. Шахматная поляна;
  3. Уборная тролля;
  4. Космическая река.

Предположим, что A = {4}, и начальное состояние i = 1, в таком случае вероятность достижения — это вероятность попасть когда-либо из Дома на Космическую реку.

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

Случайные блуждания и цепи Маркова в геймдизайне

Новая матрица переходов:

Случайные блуждания и цепи Маркова в геймдизайне

Допустим, если A — поглощающее состояние, то hi — вероятность поглощения.

Применим следующую формулу:

Случайные блуждания и цепи Маркова в геймдизайне

Подсказка, как работать с этой формулой:

1. Пройдем по каждому состоянию i от первого до последнего, обозначив их как h c индексом состояния:

1.1. Если i в множестве A, приравняем hi к 1. «Перевернутая буква Э» обозначает принадлежность, а зачеркнутая — не принадлежность. Например, i = 1 — то есть, дом — не входит в A, потому что A = {4}.

1.2. Если i в нем нет, приравняем i к сумме вероятностей переходов ij, умноженных на соответствующие hj, пока нам неизвестные.

2. Получим набор связанных h, то есть — систему линейных уравнений, где неизвестные переменные — hi. В некотором смысле это школьное x.

3. Найдем каждое hi с помощью метода подстановки или любого другого. В таблицах это делается с помощью более сложного метода.

Результат должен быть минимально возможным, но при этом каждое значение системы не меньше 0.

Проведем расчет для h14:

Случайные блуждания и цепи Маркова в геймдизайне

Проделав это для каждого состояния, получим:

Случайные блуждания и цепи Маркова в геймдизайне

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

Шаги до неизбежности

Также часто может интересовать не столько вероятность наступления события, сколько его математическое ожидание (expected value). Когда речь заходит о случайных процессах, это время перехода из начального состояния в заданное (expected hitting time). Время — абстракция, которая может быть шагом, действием, чем захотим. Время попадания также часто называют временем достижения. Если A — поглощающее состояние, время также называют временем поглощения.

Случайные блуждания и цепи Маркова в геймдизайне

Применим следующую формулу:

Случайные блуждания и цепи Маркова в геймдизайне

И вновь результат должен быть минимально возможным, но при этом каждое значение системы не должно быть меньше 0.

Вопрос: через сколько ходов алхимик застрянет на реке?

Проведем расчет для m1:

Случайные блуждания и цепи Маркова в геймдизайне

Проделав это для каждого состояния, получим:

Случайные блуждания и цепи Маркова в геймдизайне

Вот и получается, что алхимик примерно через 8 шагов будет заточен у реки (37/5 ближе к 7, но будем оптимистами — да и, как показывает практика, лучше округлять в большую сторону).

За кадром осталось:

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

Случайные блуждания

Алхимик варит зелья

Представим, что теперь алхимик делает магические зелья:

  • Начальное здоровье: 1, максимальное: 5;
  • Делаем зелье регенерации, которое восстанавливает +1 здоровье;
  • Иногда получаем плохое зелье, и оно отнимает 1 здоровье;
  • Вероятности сделать хорошее и плохое зелье равны;
  • При здоровье, равном 0, алхимик погибает, а при 5 — заканчивает эксперимент.

Вопрос: как скоро игра закончится — то есть, алхимик погибнет или полностью восстановит здоровье?

Случайные блуждания и цепи Маркова в геймдизайне

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

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

Здесь, говоря математическим языком, описано простое ограниченное симметричное случайное блуждание, потому что переходить можно только в соседние значения — например, нельзя опустить здоровье сразу с 2 до 0: при этом оно ограничено и снизу, и сверху, а также вероятности понижения и повышения равны.

Рассмотрим случай полного восстановления здоровья.

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

Случайные блуждания и цепи Маркова в геймдизайне

А если одно зелье отняло здоровье?

Для удобства будем считать, что все зеленые обозначения в формулах, графах и рисунках — плохие зелья, а красные — хорошие.

Случайные блуждания и цепи Маркова в геймдизайне

Плохое зелье должно быть компенсировано хорошим, чтобы восполнить здоровье до максимума. Таких вариантов всего три — в зависимости от того, когда мы выпьем испорченное зелье:

Случайные блуждания и цепи Маркова в геймдизайне

С ростом числа плохих зелий растет и количество возможных вариантов. Один из вариантов их подсчета — представить все в виде графа.

Для 8 зелий (2 из которых — плохие) получаем 8 возможных путей:

Случайные блуждания и цепи Маркова в геймдизайне

Возникает два вопроса:

  1. Сколько зелий можно брать? 5? 12?
  2. Можно ли посчитать это аналитически, а не с помощью графа?

Мы понимаем, что надо восполнить 4 здоровья (если начинаем с 1 и максимально — 5), выпив n-ное количество зелий. В таком случае получаем:

  • m = 4 — здоровье, которое надо восстановить;
  • p — количество хороших зелий;
  • (np) — количество плохих зелий.
Случайные блуждания и цепи Маркова в геймдизайне

Таким образом, если p — нечетное, мы не сможем полностью восстановить себе здоровье с данным запасом зелий. Зная количество хороших зелий, легко выражаем плохие. Например, для общего числа 8 получаем:

Случайные блуждания и цепи Маркова в геймдизайне

Любая попытка восстановить себе здоровье — это набор зелий, например {хорошее, плохое, хорошее, ..., плохое}. В нашем случае такой набор состоит из 8 зелий, 2 из которых — плохие. Значит, достаточно рассчитать количество сочетаний без повторений.

Напомню формулу:

Случайные блуждания и цепи Маркова в геймдизайне

Получим:

Случайные блуждания и цепи Маркова в геймдизайне

Но 28 намного больше 8! Мы что-то не учли на графе?

Нет, наоборот: когда считали через сочетания, то учли слишком много, а именно — ранние кейсы смерти и восполнения жизни.

Случайные блуждания и цепи Маркова в геймдизайне

Ранние смерти:

  1. Игрок изначально выпил плохое зелье;
  2. Игрок выпил хорошее, а потом два плохих зелья.

Раннее оздоровление:

  1. Игрок выпил 4 хороших зелья подряд;
  2. Игрок выпил хорошее, потом одно плохое и 4 хороших.

Если посчитаем все такие комбинации, получим как раз 20 лишних кейсов. И по итогу 8 возможных.

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

Случайные блуждания и цепи Маркова в геймдизайне

Легко заметить, что это почти обычная бесконечно убывающая прогрессия, сумму которой можем найти:

Случайные блуждания и цепи Маркова в геймдизайне

Примем за веру, что мы не можем потеряться где-то в пространстве и постоянно пить то хорошее, то не очень зелье, то есть вероятность потерять алхимика:

Случайные блуждания и цепи Маркова в геймдизайне

За кадром осталось:

1. Знаменатели дробей — вероятность (1/2)^n, где n — количество зелий всего.

2. Применение формулы БГП к почти БГП не совсем верно, ведь отношение двух соседних дробей получается различным, что противоречит сути БГП. Однако мы можем посчитать среднее и получить результат в 17/24.

3. Для получения вероятности гибели алхимика можем проделать точно такую же операцию, как и для вероятности полного восстановления, что даст нам результат в 11/14.

Общий случай

Пусть начальное здоровье нашего алхимика — current (c), а максимальное — max (m). В таком случае, если c — ноль, то вероятность восстановить здоровье — 0, а если m, то 1. Если же c лежит где-то между, вероятность будет суммой слева или справа:

Случайные блуждания и цепи Маркова в геймдизайне
Случайные блуждания и цепи Маркова в геймдизайне

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

Случайные блуждания и цепи Маркова в геймдизайне

Решив эту систему уравнений, получим:

Случайные блуждания и цепи Маркова в геймдизайне

Если вернемся к нашему случаю, где c = 1, а m = 5, то получим 1/5, что почти совпадает с 3/14 (абсолютная разность в 0.014).

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

Случайные блуждания и цепи Маркова в геймдизайне

Сколько бы зелий алхимик ни выпил, рано или поздно он погибнет.

Сколько зелий придется выпить?

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

Логично, что крайние значения — нулевые, а n-ное — сумма соседних и ещё одно зелье в силу линейности величины:

Случайные блуждания и цепи Маркова в геймдизайне

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

Случайные блуждания и цепи Маркова в геймдизайне

Интересные факты:

1. Вероятности восстановиться полностью, находясь изначально с n, образуют арифметическую прогрессию;

2. Набор времени перехода — строка из треугольника Паскаля с номером m, но с нулями на краях.

Если вернуться к тому, что возможен оверхил, получим:

Случайные блуждания и цепи Маркова в геймдизайне

Подсказка, откуда здесь берется бесконечность:

Опустим строгое определение предела. Пусть m — бесконечность. Бесконечность — это что-то очень огромное: мы можем из нее вычитать, прибавлять, умножать — и все равно получим бесконечность. Хотя иногда бывают исключения!

Можно выпить бесконечное количество зелий, но погибнуть в любом случае.

Тем не менее, вполне возможен случай, когда вероятность плохого зелья выше, чем хорошего (p), что вполне логично для алхимиков. В таком случае получим:

Случайные блуждания и цепи Маркова в геймдизайне

За кадром осталось:

1. Решение рекурсивных выражений;

2. Вывод формулы для несимметричных блужданий.

Реальные Кейсы

Пришло время приступить к небольшой практике. Когда все это вообще может пригодиться?

Сундуки из сундука из сундука...

Предположим, что в игре есть следующая механика сундуков:

  • У игрока изначально есть 1 сундук;
  • С вероятностью 0.4 из него выпадут 2 таких же;
  • С вероятностью 0.6 не выпадет ничего.

Вопрос: сколько в среднем сундуков откроет один игрок?

Случайные блуждания и цепи Маркова в геймдизайне

Можем считать, что n — текущее количество сундуков, которое изначально равно 1, а каждое действие либо увеличивает n на 1, либо уменьшает на 1. Увеличение на 1 обусловлено тем, что игрок как бы потерял 1 сундук, но получил 2 новых. Условия очень хорошо ложатся на концепцию блужданий. Следовательно, просто применяем формулу:

Случайные блуждания и цепи Маркова в геймдизайне

Заточка меча

Предположим, что в игре есть следующая механика улучшения меча:

1. У игрока изначально меч 1 уровня, currentLevel = 1;

2. Максимальный уровень — 4;

3. Игрок может попытаться поднять уровень меча с помощью прокачки. Однако в процессе меч может сломаться и понизить свой уровень;

3.1. Цена прокачки зависит от текущего уровня: 10 ∗ currentLevel;

3.2. Вероятность улучшения: 1 − currentLevel/10;

3.3. Если меч сломался при попытке улучшения с 1 уровня до 2, он остается 1 уровня.

Вопрос: сколько в среднем игрок потратит монет, чтобы улучшить меч до максимального уровня?

Представим механику в виде графа, вершины которого — уровни, а ребра — вероятность улучшения:

Случайные блуждания и цепи Маркова в геймдизайне

Это обычная цепь Маркова, и надо просто найти m1 с A = {4}, то есть — среднее количество шагов от 1 до 4 уровня. Воспользуемся формулой:

Случайные блуждания и цепи Маркова в геймдизайне

Получим: m1 ≈ 4.72, средняя цена улучшения = 20, и их осталось перемножить. Таким образом, в среднем игрок потратит около 94 монет, чтобы улучшить меч с 1 уровня до 4.

Вопрос на засыпку: а справедливо ли вот так просто брать среднюю цену улучшения?

Денежный поединок (Gambler’s Ruin)

Пусть в игре существует следующая механика поединка:

1. Есть два игрока, у которых n и m монеток;

2. Игроки последовательно подбрасывают монетку:

2.1. Если выпала решка, первый игрок забирает у второго одну монетку;

2.2. Если орел, то второй — у первого;

3. Игроки могут играть, пока у обоих строго больше 0 монеток.

Вопрос: с какой вероятностью проиграет первый игрок?

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

Случайные блуждания и цепи Маркова в геймдизайне

Переходы имеют вероятность 0.5, ведь мы просто подбрасываем монетку, кроме крайних — у них 1. Если проиграл или выиграл, то это окончательно.

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

Случайные блуждания и цепи Маркова в геймдизайне

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

Получим вероятность победы второго игрока:

Случайные блуждания и цепи Маркова в геймдизайне

Соответственно, это и есть вероятность проигрыша первого.

Дополнительно можем посчитать количество бросков:

Случайные блуждания и цепи Маркова в геймдизайне

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

Заключение

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

Случайные блуждания и цепи Маркова в геймдизайне

Ах, да, все это можно было посчитать с помощью метода Монте-Карло — но, во-первых, это скучно, а во-вторых, полезно знать, что стоит за результатами, а не принимать их на веру. И еще надо уметь писать простые скрипты! Не бойтесь математики, развивайтесь и делайте хорошие игры!

Литература

Иллюстрации by shabbyrtist

Лев Кобелев
Геймдизайнер в Whalekit
119119
19 комментариев

УРАА
ГЕЙМДИЗАЙН ЧЕРЕЗ ВЫШМАТ
НАКОНЕЦ Я ДОЖДАЛСЯ
БУХАЕМ СЕГОДНЯ

18
Ответить

Но 28 намного больше 8! Мы что-то не учли на графе?

Но ведь всё верно, 8! намного больше 28

6
Ответить

Можно пример реальной игровой механики в которой может возникнуть подобная задача?

3
Ответить

Это больше вопрос баланса, чем конкретной механики. Баланс требуется для любой игровой подсистемы.
Наиболее знакомый и распространенный пример - потребление ресурса, в тех же мобайл играх на него завязана вся монетизация. Какой ресурс - энергия, камешки, волшебные желуди - уже не важно: главное, что он получается и тратится. Зачастую есть таргет на кол-во просмотров рекламы и/или инапп покупок. Зная среднее кол-во шагов - можно легко подсчитать interstitial. Зная какие точки и как часто они будут посещены (с учетом разных весов перехода по графу) - разместить ресурсы так, чтобы для прохождения ивента требовалось Х дней или обязательно пополнить энергию, затрачиваемую на переходы, на $Y. В противном случае придется делать "на глаз" или по результатам тестов.
Но речь не о мобильном зле. Даже в сингле есть ресурсы и их потребление. Проведя предварительные расчеты для какого-нибудь данжена, можно свериться с общим балансом игры, заметить проблемные места - недостаточно ресурсов, слишком частое попадание в локацию со смертельной ловушкой, слишком легкий крафт с высоким net gain и т.п.

3
Ответить

Если вернёмся к нашему случаю, где с=1, а m=5, то получим 1Как у вас вышло 1? Pc =1/5. А иначе что вы имеет ввиду?
И даже если верно, то что ещё за
разница в 0.014Разница чего с чем? 1 и 13/14? Но это ведь неверно

Ответить

Спасибо за замечание! Вышла опечатка… конечно же, там не 1, а 1/5, и не 13/14, а 3/14. Разность между величинами в порядке.

Уже поправили в тексте!

3
Ответить

Спасибо за прекрасную статью, неожиданно для себя открыл новую перспективу.
p.s мне кажется статье не хватает тегов, для большего охвата аудитории.

1
Ответить