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

Перед вами здание- склад, сгенерированное процедурно:
image

Об игре


«Distrust» задумывался как survival в условиях заброшенной полярной станции, где игроку предстоит управлять командой из нескольких человек. Изначальной идеей было то, что база генерируется процедурно, что это будет 2D в изометрии, а геймплей это не только стандартный для жанра поиск ресурсов и поддержание статов персонажей, но и различные взаимодействия между персонажами, диалоги и наблюдения, цель которых — вычислить среди участников команды нечто, которое является угрозой для остальных персонажей.

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

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

Описание игрового мира


Игровой мир Distrust представляет собой полярную станцию или базу, которая состоит из нескольких зон. Зона представляет собой территорию, огороженную забором с одним переходом в следующую зону. Проход представляет собой запертую калитку, бронированную дверь, просто завал из снега и тому подобное. Чтобы открыть проход в следующую зону, игроку требуется выполнить определённый квест: расчистить сугроб трактором, собрать бомбу и взорвать дверь, подобрать код и прочее. Цель игрока — вывести персонажей с базы, пройдя все зоны с первой до последней и, соответственно, выполнив все квесты.

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

Геймплей заключается в выполнении разных задач героями команды под управлением игрока. Задачи — это 70% геймплея игры. Они могут быть совершенно разнообразными — начиная с лутания тумбочки и заканчивая жертвоприношением, но по сути всё это одна и та же сущность — таска, как мы её называем, разница лишь в конфигурации. Нетрудно догадаться, что тасок в нашей игре огромное количество, и функционал, который они предоставляют, огромен!

Визуальный язык программирования для настройки тасок
image
Основная смысловая нагрузка всех тасок — это действия, которые применяются к персонажам в процессе выполнения таски. Эти действия могут быть выполнены перед стартом таски, в процессе с определённой периодичностью или же по завершении таски. На картинке изображены действия по окончании весьма изощрённой таски. Действия или экшены, как мы их называем, меняют статы персонажам, добавляют или снимают эффекты и вообще представляют из себя очень многочисленные сущности, которые служат инструментом настройки игры для геймдизайнера, и совершенно внезапно для нас они превратились в визуальный язык программирования! Поначалу лишь проницательный техлид подшучивал над геймдизайнером, дескать тот, настраивая всё это, занимается ничем иным как программированием, но когда в и без того огромном списке действий для персонажей появились if-then-else action, for-each-hero action, при том, что все действия могут быть с безграничной вложенностью, а пару из них даже с рекурсией, то смех сдержать было уже невозможно всем, особенно в те моменты, когда очередную особо изощрённую таску и её экшены приходилось настраивать всем миром с привлечением программистов.

Создание игрового мира описано ниже и визуально выглядит так:
image

Реквизит


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

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

Основная часть бюджета каждой зоны — лут и список реквизита, который может быть использован при генерации. Под реквизитом мы понимаем объекты игрового мира, с которыми так или иначе взаимодействуют персонажи. Это могут быть тумбочки, кровати, электрогенераторы, печи, шкафы и так далее. Реквизит при генерации мы разделяем на три группы:
Scenery — не несут никакой функциональной нагрузки и служат для того, чтобы комнаты выглядели естественно и разнообразно — это стулья, ящики, трубы и т. п.
Loot — призваны разместить в себе лут — это шкафы, тумбочки, складские полки и т. п.
Utility — всегда несут на себе определённую функциональную нагрузку и необходимы персонажам для выживания — это кровати, печи, плиты и т.п.

Ассет с бюджетом зоны
image
Бюджет зоны состоит из списков лута, реквизита, дверей и прочих списков

Ассет с бюджетом лута
image
В бюджете лута для каждого итема указаны таски, в которых он может оказаться, сколько итемов выдаёт конкретная таска и сколько итемов в зоне должно быть

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

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

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

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

Ассет бюджета утилитарного реквизита
image

И последнее, что нужно знать для понимания работы генератора на этом этапе — это понятие «тэг комнаты». По сути это просто перечисление, которое характеризует тип комнаты, например, кухня, спальня, котельная и так далее. В зависимости от типа комнаты в ней может оказаться реквизит лишь соответствующего типа, кровать не может оказаться на кухне, а холодильник — в котельной. Конфигурация реквизита также содержит список тэгов комнат, в которых он может быть расположен для сопоставления. Полученные сущности мебели генератор распределяет в зависимости от тэга, и на выходе отдаёт словарь, в котором ключом выступает тэг комнаты, а значением является реквизит, разделённый по трём спискам: loot, utility и scenery. Причём первые два представляют из себя готовые и настроенные сущности мебели, в то время, как третий список — это лишь конфигурации реквизита, который выступает в качестве декораций. Генератору требуется расставить сущности из первых двух списков, в то время, как третий — вспомогательный и нужен для более естественного заполнения комнат. Итак, теперь мы можем вкратце описать происходящее на данный момент:
На основании тасок, выдающих лут, подготовленных на одном из шагов, и конфигурации реквизита, а также тэгов комнат, генератор создаёт словарь, в котором для каждого типа комнаты имеется два списка, которые необходимо расположить где- либо в зоне, плюс один вспомогательный, из которого генератор будет создавать новые сущности налету по необходимости.

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

Здания и комнаты


Так выглядит комната непосредственно перед инстанцированием стен, пола, крыши и мебели:
image

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

Ассет шаблона комнаты
image

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

Генерация форм


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

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

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

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

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

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

Генератор изучает весь список реквизита, который уже поставлен в комнату, на предмет соблюдения соотношения мебели трёх типов. Так, например, после постановки холодильника в кухню в ней имеется одна единица реквизита, и соотношение выглядит как 100% лутовых, 0% утилитарных, 0% декоративных, но в конфигурации шаблона кухни указано MaxLootablePercentage равен 0.5, то есть лишь 50% мебели в комнате должно быть лутовой, поэтому следующим на вставку окажется реквизит из списка утилитарных, он будет выбран случайно и пусть это будет печь. Чтобы поставить печь, генератор также выбирает случайную точку так, чтобы та не угодила в стену, но помимо этого генератору нельзя допустить, чтобы печка оказалась в холодильнике! Для этой цели мы используем встроенное решение от Unity — коллайдеры.

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

Генератору на эту процедуру отводится определённое количество попыток, дабы избежать зацикливания. Если за указанное число попыток генератор не смог разместить печь, то пробует поставить другой объект, пока не попробует всё из двух доступных списков — утилитарного и сценарного (так как в кухне на данный момент есть лутовая мебель, но нет других типов) с целью соблюсти отмеченное выше соотношение. В случае если вставить больше реквизита не удаётся, или площадь комнаты заполнена на значение Filled Space, то комната считается заполненной. На этом этапе участвует множество и других правил, описание которых навевает мысли об Икее, — среди них то, должна ли мебель стоять вплотную к стене или наоборот на расстоянии, прикроватная тумбочка ставится рядом с кроватью и многое, многое другое, не столь важное в этом описании, однако, одно правило всё же стоить отметить, без него комнаты выглядели как шахматное поле — каждая единица реквизита немного вращается генератором на случайный угол, диапазон значений которого также указан в конфигурации. Эта мелочь оказалась действительно важной и сделала комнаты намного более естественным и живыми.

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

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

Заключительный этап


С предыдущего этапа генерации мы имеем фактически готовый игровой мир. На сцене пока что нет игровых объектов, но достаточно вызвать один метод каждой из созданных сущностей, как он появится! Но прежде генератор осуществляет ещё одну важную процедуру — определяет позиции зданий внутри зоны. До сих пор каждое новое здание находилось в точке ноль ноль и нас не волновало, что их там может быть несколько. Генератор случайным образом выбирает направление куда двигать здание от нулевой позиции и смещает его вместе со всем содержимым с заданным шагом до тех пор, пока форма здания не перестанет пересекаться со всеми остальными формами зданий и расстояние до всех других зданий не станет больше либо равным, указанному в конфигурации. Проделав эту процедуру, генератор на основании положения зданий определяет форму зоны. По контуру этой формы будет построен забор, а также форма зоны будет учавствовать в аналогичном зданиям процессе «раздвигания», только с другими зонами. После генерации всех зон внутри базы, они также находятся в позиции 0:0 и раздвигаются друг от друга таким образом, чтобы избежать пересечения, но сохранить при этом общую сторону, в которой будет построена калитка.

Можно считать генерацию законченной по завершении описанного этапа. Все сущности базы созданы и находятся на своих местах. Далее в дело вступает небезызвестный плагинчик из Asset Store под названием Dungeon Architect, но, поверьте, от его использования почти ничего не осталось. Алгоритмы, работающие с геометрией и теорией вероятности получились всё же самописными, а не из плагинов. В нашем проекте Dungeon Architect занимается лишь расстановкой тайлов снега, стен, крыш, одним словом рутиной. Немного труда стоило бы нам отказаться от него вовсе, но поначалу казалось, что он решит все наши проблемы и сделать генерацию с ним будет просто, а желания и времени выпилить его полностью просто не было. Занимается он уже непосредственным интсанцированием объектов и префабов на сцену.

Ещё немного генерации:

image

Генерация улицы
image
Набросок плана генерации улицы
Внутри зон, помимо зданий, также генерируется реквизит на улице, освещение разного типа и тропинки, причём тропинки генерируются с помощью каноничной реализации алгоритма Ли буквально так, как он описан в Википедии

Вместо послесловия


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

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

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


  1. FadeToBlack
    19.07.2017 12:19

    Поздравляю с первой статьей! Пиши еще :)


    1. Moximko
      19.07.2017 15:12
      +1

      Спасибо, обязательно напишу как будет что- то такое же вдохновляющее!)


  1. Reeze
    19.07.2017 13:00

    Интересная идея. Чем-то напоминает The War of Mine.
    Надеюсь, что такого параметра как депрессия тут не будет.


    1. Moximko
      19.07.2017 15:14

      Нет, стата депрессии в игре нет.


  1. Xop
    19.07.2017 16:19

    Очень круто — и генератор, и стилистика картинки. По затратам не оценивали работа по созданию генератора vs. работа по созданию сотни комнат?


    1. Moximko
      19.07.2017 16:21

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


  1. Tiendil
    19.07.2017 16:47
    +1

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

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


    1. Moximko
      19.07.2017 17:13

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


      1. Mingun
        19.07.2017 19:02
        +1

        Здесь бы очень подошла gif'ка или небольшое видео на тему «строительства базы с нуля».


        1. FadeToBlack
          20.07.2017 09:43

          Максим обязательно сделает такую гифку, таску я ему уже завел )


        1. Moximko
          20.07.2017 17:09
          +1

          Подготовил отличную gif'ку с процессом генерации и скриншот комнаты перед инстанцированием префабов!


    1. FadeToBlack
      20.07.2017 09:46

      А у нас и есть DSL! У нас куча правил и ограничений, селекторы и многое другое на гуишном "языке программирования". Если бы Максим ставил себе целью описать все это, то это был бы цикл из десятка статей.


      1. Tiendil
        20.07.2017 10:47

        Можно и так смотреть :-)

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


        1. FadeToBlack
          20.07.2017 11:01
          +3

          Попробуй объяснить это нашим геймдизайнерам, и тебе придется обучить их программированию на C#. Сейчас они программируют, не догадываясь об этом :)


  1. Stas_tarantas
    20.07.2017 06:02
    +1

    Отличная статья, и судя по всему, игра.


    1. Moximko
      20.07.2017 06:06
      +1

      Спасибо! Демо- версия уже сейчас доступна бесплатно, можете оценить.


      1. Idot
        20.07.2017 21:31

        Что делать с этими летающими шарами?


        1. Moximko
          21.07.2017 10:15

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


          1. Idot
            21.07.2017 13:40

            Нашёл в дёмке, что в чёрные шары нужно светить фонариком, но ко мне прилетают белые колючие шары :(


            1. Stas_tarantas
              24.07.2017 08:46

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


  1. fly_style
    23.07.2017 13:09

    Версии под OS X не планируется?


  1. Moximko
    24.07.2017 09:11

    Версия под OS X точно планируется, возможно, чуть позже версии под Windows.


  1. Smolski
    24.07.2017 10:09

    Чувствуется качество, ребята, так держать!


    1. Moximko
      24.07.2017 13:46

      Очень приятно, рады стараться!