Pull to refresh

Comments 51

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

Ну я использовал именно такой подход. Очень удобно на самом деле — у тебя есть два под-проекта и каждый из них довольно независим.
В случае подобного разделения можно словить очень неприятный момент, а именно — отличие реализации mono в unity и в том, что использовалось при разработке / компиляции / тестировании логики. За этим нужно следить.

Нет. Так работает только какой-нибудь очень далекий от движка объект, например UI или дизайнерские задвики по AI. Вообще желательно всё писать поближе к движку, чтобы потом не оказаться с 1.5 fps. Иначе графику, физику, анимацию предеться очень оптимизировать и упрощать. Вообще даже бывает, что следят за ms, которые тратятся на обработку скриптов. И операции с матрицами и векторами в скрипты стараются не выносить.

Нет. Так работает только...

А вам не кажется, что это очень проектозависимо?
Мне нравится ваш подход.
Ничто не мешает, к примеру, биндить физику Unity3d на модель, апдейтить модель, биндить модель к физике Unity3d — я почти так и делал, вполне удобно и на FPS влияние минимальное.
Но моя модель про анимации ничего не знала, не придумал как подружить их грамотно. Как у SpaceLab с этим дела обстоят? Есть ли необходимость в физике и анимациях на уровне проекта с игровой логикой?
На уровне проекта с игровой логикой — нету. Конечно, от завязки на View не обойтись. Но стараюсь просто создать абстрактный эвент в том месте, где необходимо запустить анимацию. Например, у меня есть какая-то комплексная команда и в определенном случае в опреленном месте этой команды необходимо запустить анимацию. Я делаю в этом месте что-то вроде:
core.Publish(new CrewLegBrokenEvent( crewMember ));


А в коде вьюшки что-то вроде:
public void On(CrewLegBrokenEvent ev) {
  LaunchAnimation( ev.crewMember )
}


Таким образом у меня модель ничего не знает про рендер.

Это не подход вообще это очень низкий ентри левел. Писать код чтобы просто заработало.

Да что вы вцепились в эту строчку? Я ж не пишу все тесты так. Это единственный странный тест, который просто проверяет, что лично я нигде не налажал с подключением тестового проекта к игре. А withoutuniverse говорит о подходе описанном в статье в целом. Или весь подход неверен из-за того, что вам кажется лишней одна строчка?
Вообще, это довольно частый кейс, когда бизнес-логику выносят в отдельный проект. Особенно это актуально для игр с мультиплеерной составляющей, когда надо синхронизировать состояния игры. Одна и та же логика крутится и на клиенте и на сервере. Не тащить же весь юнити проект в сервер, верно?
Почему нет? У нас, к примеру, серверные инстансы тоже на Unity. Таким образом очень много кода переиспользуем. Чисто клиентские фишки завернуты в один define, чисто серверные в другой.
It depends. Если проект — мультиплеерный шутер, например, с активным использованием физики — да, однозначно придётся поднимать инстансы на сервере. Ну или иметь головняк с подключением сторонней физ. библиотеки к юнити (типа Bullet), но такое часто не стоит свеч.
Из минусов — некоторое усложнение серверной архитектуры, т.к. поднять инстанс game сервера и заставить его корректно общаться с клиентами и другими серверами — это не то же самое, что запустить логику игры из dll в отдельном потоке.
Само собой. Просто вы весьма категорично высказались, вот я и вставил свои 5 копеек.
Да в целом там в этом плане проблем не было, я имею ввиду с точки зрения написания инстансов. Проблемы больше в плане взаимодействия (сервис дискавери и т.п.), но эти проблемы независимо от реализации будут.

В своем обучающем pet project на unity3d тоже думаю о выносе логики в отдельную сборку, благо четко отделены неймспейсы Core и UnityLogic (по сути — модель и представление, как бы странно в контексте юнити это не звучало) и в Core от юнити только вектора да Assert'ы используются.


Вот только редактор имеет обыкновение пересоздавать файлы .sln и .csproj, поэтому добавить в решение отдельную сборку довольно сложно (хотя, вроде если сильно исхитриться, то можно), а значит скорее всего придется делать их вообще разными solution'ами, что слегка неудобно — одновременно редактировать и ядро и юнити логику не получится, возможны проблемы с подключением какого-нибудь Vector2 от юнити в этот внешний проект (не проверял, т.ч. может быть тут проблем нет).


Кроме того, не очень понятно, как не устроить в VCS спам обновлениями какого-нибудь Plugins/Core.dll, да и вручную постоянным копированием перекомпилированного Core заниматься не очень хочется.


Т.ч. пока активных действий не предпринимаю и Core внутри основного .csproj. Чтобы жить было веселее, встроил известный плагин/хак, позволяющий использовать сахар из C# 6, а для тестов воспользовался Unity Test Tools.


Но как-то костыльно это все( Все таки хочется выделить ядро в отдельный "чистый" проект, чтобы еще меньше зависеть от движка/удобнее тестировать и т.д. А у юнити проекта создать свои тесты и свои правила.
Но нужно, по крайней мере, автоматизировать сборку Plugins/Core.dll, причем кроссплатформенно. Хотя, вероятно это очень просто решить банальным output directory в проект юнити.

Вот только редактор имеет обыкновение пересоздавать файлы .sln и .csproj, поэтому добавить в решение отдельную сборку довольно сложно (хотя, вроде если сильно исхитриться, то можно), а значит скорее всего придется делать их вообще разными solution'ами, что слегка неудобно — одновременно редактировать и ядро и юнити логику не получится,

Я недавно придумал использовать для этого символические ссылки.


Создаёшь отдельный солюшен (CoreSolution) и проект (CoreProject) в Студии. Внутри CoreProject делаешь папочку Core и весь код хранишь в ней. А в проекте Unity в папку Assets кидаешь символическую ссылку на папку Core. В результате, писать и редактировать код можно и в CoreSolution, и в солюшене Unity, и даже одновременно.


Косяков такого подхода пока не заметил. Единственная неприятность ― если забыться и создать новый cs-файл в Unity внутри папки Core , то в CoreProject его потом надо будет добавлять вручную через Add -> Existing Item... Этот момент несколько раздражает.


Для удобной работы со ссылками есть расширение к Проводнику ― Shell Link Extension.


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

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

Вот такая конструкция добавит все файлы из поддерева в проект:


<ItemGroup>
  <Compile Include="Core\**\*.cs" />
</ItemGroup>

Необязательно, кстати, симолические ссылки использовать — в проект можно и какой-нибудь ..\..\Core\**\*.cs добавить.

У меня изначальная задумка была не столько для отделения «core-кода» от «не-core-кода», а скорее для того, чтобы вынести код, который я использую в каждом проекте Unity, куда-нибудь в одно место. Чтобы не хранить в десяти Unity-проектах десять копий одних и тех же, но чуть-чуть отличающихся исходников; потому что со временем перестаёшь понимать, где лежит более свежая версия, где более старая, и чем они отличаются.


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


Тут конечно появляется вероятность сломать что-нибудь в старом проекте, внеся изменения в общий код. Но пока в Unity не появится родная поддержка чего-нибудь типа NuGet, я думаю, это будет наименьшим злом.

Кстати, насколько я знаю, для подобных целей git submodule обычно используют. А чтобы не привязываться к структуре каталогов подмодуля можно объединиит подходы — в каком-нибудь ThirdParty хранить подмодуль и на него делать симлинк (или что-то другое, не знаю точно через что относительные ссылки делаются) в проект. По крайней мере, после клонирования на другую машину не потребуется доп. действий и вообще думать о зависимости.

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

Чтобы жить было веселее, встроил известный плагин/хак, позволяющий использовать сахар из C# 6

Решарпер не так давно начал понимать туплы. Теперь можно жить ещё веселее и потихоньку начинать применять C# 7. Правда всерьёз только на Windows.

Вообще, ИМХО, вот именно так и надо писать правильную архитекутру (не вседа правда получается). Все слои программы должны быть максимально абстрагированы друг от друга: UI ничего не знает о бизнес логике, внутри бизнес-логики сущности ничего не подозревают друг о друге, если мы исключим из программы UI, то она должна скомпилироваться и работать, ну и т.д.

Это удобно, модульно, масштабируемо и конфигируруемо.

В противном же случае мы получим настоящий ад зависимостей и хрупки классы. Начнем что-нибудь исправлять и получим по лбу. В Unity3D собственно старая-новая система анимации (Меканим что ли она называется) вот прям ИМХО яркий пример того как не надо делать: если все делать как по учебнику, то анимация и игровая логика будут прибиты друг к другу гвоздями, фиг потом отдерешь.

Вся проблема только в том, как только продумать такую архитектуру.
Assert.IsInstanceOfType(new Core(), typeof(Core));

А делать Assert.IsEqual(2*2, 4) вы не пробовали? Или там Assert.IsNotEqual(true, false)?

Пробовал, но цель ведь — проверить, что проект корректно настроен, заимпортить Core. Или вы думаете, что я питаю иллюзии о практической ценности данного теста в долгосрочной перспективе?

Всегда начинаю писать тесты с assert true.


Это же стандартная практика — проверить, что сам тестировочный фреймворк работает, проект корректно настроен и нужные классы импортируются.

Только для начальной проверки или тест отсается?


Если первое, то можно и сразу тест на логику писать. если прошел — все работает, все подгружено.
Если второе, то в каком классе лежит такой тест и один ли он там?
А если третье, то это тесты самого фреймвека и они должны лежать в проекте с ним и скорее всего они там и так есть.

Assert(true) сработает и на чистом тест-проекте, в которые даже не были добавлены ссылки на тот проект, что будет тестироваться. А проверить на то, что «тестировочный фреймворк работает» — обязанность его разработчиков, как по мне. Не встречались ещё такие, которые бы не работали, но может мне везло.
Проверки типа Assert.IsInstanceOfType(new Core(), typeof(Core)) не несут никакой ценности, разве что проверить что CLR умеет правильно ассоциировать тип с экземпляром класса (думаю, это бы за 15 лет уже заметили).
Но опять же, если в Юнити такая проверка оправдана и может вернуть false, то это печально
Отличная игра для тех, кому понравилась экономическая часть XCOM: Enemy Unknown, но показалась маленькой, относительно остальной игры.

Кто-нибудь знает наподобие игры, помимо Fallout Shelter?
Сама задумка интересная, я что-то подобное делал, когда писал игры на LibGDX.
В вашем случае это возможно, так как легко обходитесь без компонентов движка.

Есть парочка советов:
1) Словарь, где ключом является enum — это плохо. Лучше заменить на int, по необходимости этот enum приводить к int, это исключит ненужный боксинг/анбоксинг.
2) Не знаю, как часто вы используете LINQ, но от таких штук лучше отказаться. Лишние аллокации.
3) foreach для List лучше не использовать, опять же, лишние аллокации. Если редко вызывается, то ещё ладно, если +- каждый кадр, то плохо.
1) какая разница, enum или int? Элемент в словарь добавляется по его хешкоду, метод GetHashCode() переопределен у каждого наследника System.ValueType (будь то enum, или int), бакеты внутри там тоже дженерики, никакого боксинга никогда не происходит. Если даже я что-то и пропустил, пока читал код Dictionary, то замена одного value-типа на другой точно не даст никакого преимущества.
2) как ещё вы предлагаете узнать, содержится ли в коллекции интересующий нас элемент, кроме перебора всей коллекции?
3) как по-другому проходить по элементам коллекции? :) Под «лишними аллокациями» понимается создание енумератора? Да, он весит больше, чем интовый индекс, но не настолько, чтобы вызывать панику и бежать переписывать все циклы на for

ЕМНИП, там какой-то баг в рантайме, который давным-давно исправили в Mono, но никак не сольют в Unity.

Тогда нужно уточнять, что касается только Юнити, а не платформы в целом. А то потом молодежь с толку сбивается, и начинают городить…
1) Пост про Unity же.
2) Именно for.
3) Ещё раз, мы говорим про C#, про старую баженую Mono версию. Если енумератор создаётся +- каждый кадр, то это лишние аллокации, лишние нагрузки на gc, зачем?

3) На дворе Unity 5.5 и другой компилятор C#. Он в foreach на списках не аллоцирует.


К тому же, раз ядро в статье пишется в отдельном проекте вне Unity, то и компилируется оно, скорее всего, тоже вне Unity.

Не забывайте, что в Unity под капотом Mono и половину оптимизаций, включая безбоксовый конверт enum <-> int там нет. Я тоже было хотел высказаться, мол, с хрена ли enum медленнее, даже посмотрел чего там в IL'е творится — все идеально, современному C# компилятору пох, он умеет. Потом решил проверить, чего в Mono, а там реально boxing на boxing'е, пока сам не подлатаешь. Так что совет для Unity/Mono имеет смысл.
О, спасибо за инфу. А кто-нибудь встречал, что Мигель планирует делать с Mono дальше, с учетом .Net Core и того, что он сейчас часть MS? Существует какая-то точка, когда это все станет одним целым или хотя бы планы?

Я так понимаю, в среднесрочной перспективе будут существовать три платформы: .Net Framework для Windows, .Net Core для трёх основных платформ и Mono для всего.


Фишка .Net Framework ― богатство функционала.
Фишка Mono ― встраиваемость и работа на всём, что движется.
Фишка .Net Core ― автономность, платформа идёт вместе с приложением.


В краткосрочной перспективе Mono переходит со своего компилятора на Roslyn, и все три ветви унифицируют свои библиотеки под флагом .Net Standard.


Примерно так я себе это представляю, но могу ошибаться.

.Net Core уже умеет запускаться на чайнике, по сути, дело только в RID'ах и официальной поддержке (Android уже почти, кстати). Мы, например, пилили поддержку Alpine под x86-64 (хочется тоненький docker-контейнер) и ARM64, а это и так своеобразный дистрибутив (одно совокупление с musl чего стоит, кучи библиотек под него нет и т.д.) + архитектура. В итоге, к работе подключились чуваки из MS и скоро поддержка Alpine всех сортов появится официально.
Вот тут отлично расписано, кто есть кто в .NET'ой перспективе:
http://www.hanselman.com/blog/WhatNETDevelopersOughtToKnowToStartIn2017.aspx
1) В родной среде исполнения вроде бы будет все норм, а вот в Mono были времена когда начинался боксинг-анбоксинг. Сам удивился когда обнаружил.

2) За LINQ-сахаром будет скрыт тот же for на самом деле: иного пути в поиске просто нет — надо перебирать элементы коллекции, разве что можно извратиться и сделать, например, бинарный поиск (сложность которого логарифмическая, а не линейная, т.е. работать он будет быстрее) или еще чего.

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

3) for Добавлю еще, что foreach не совсем таки безопасный, я бы сказал, что он совсем не безопасный. Enumerator не отслеживает состояние родительской коллекции после своего создания и может вернуть (при перечислении) объекты, которых там уже нет. О чем собственно упоминается в msdn. Исключения вылетающие на foreach — наверное самые частые которые мне приходилось разгребать в чужом коде и заменять foreach на for в таких ситуациях.
Ну и да, применение foreach подразумевает, что:
  • создается новый объект — итератор;
  • на каждой итерации идет вызов метода MoveNext;
  • на каждой итерации идет обращение к свойству Current, что равносильно вызову метода;

Конечно же родной JIT компиляторе в родной среде исполнения вумный и попытается соптимизировать. Но получается не всегда, а уж Mono так вообще такое не гарантирует (хотя последние версии пытаются), а древний Mono (вроде бы 2-ой) работающий под капотом Unity3D так вообще даже не попытается этого сделать. Хотя буквально пару месяцев назад вот наталкивался на ситуацию, когда foreach натурально сводил сума GC, который начинал молотить как сумашедший и всем становилось хорошо. И это было в .NET 4.6 под родимой виндой

В то время как глупый for просто обращается по индексу и все. Никаких премудростей и работать он будет везде одинаково, независимо от платформы, версии .NET, положения звезд, фазы Луны и т.д.

4) Я еще добавлю про var. Во-первых, опять таки это делает код трудно читаемым
натыкаешься на:
var a = Petja.GetUnknowVar();

И сидишь и думаешь, а это что вообще такое? Строка, циферка или какой-то класс, а может структура? Лезешь внутрь метода, а там снова Var на Var'e и Var'ом погоняет. Так и сидишь, ковыряешься в чужих потрохах.

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

Хорошо шо хоть var проверяется еще на этапе компиляции. Правда тоже на умность компилятор надеется не стоит, напишите var i = 5 — и компилятор подставит int32, а на переполнения из-за Var я уже тоже натыкался. А вот dynamic… а применение динамических типов ОТКРЫВАЕТ ДВЕРИ АДА. То есть, оно говорит компилятору, что это динамический ВЫЗОВ АДСКОГО СОТОНЫ, и надо перенести все проверки типа (и ошибки соответственно) на время выполнения(с). Правда к Unity3D это вроде бы еще пока отношение не имеет

Не для того Хейлсберг создал строго-типизируемый язык программирования, что бы потом на нем пытаться писать «как буд-то это ЖабаСкрипт».

В общем когда сахара много, наступает момент когда код и его исполнение становится трудно предсказуемым
4) Я еще добавлю про var. Во-первых, опять таки это делает код трудно читаемым

>И сидишь и думаешь, а это что вообще такое?
Мышку навести, студия и покажет, что это. Магии же здесь нет, тип всегда точно определён.
Использовать var или нет — дело программиста. Но уж в явных местах лучше использовать, именно для улучшения чтения:
SomeClass a = new SomeClass();
List<KeyValuePair<int, SomeClass>> b = new List<KeyValuePair<int, SomeClass>>();
OtherSomeClass c = new OtherSomeClass();
// vs
var a = new SomeClass();
var b = new List<KeyValuePair<int, SomeClass>>();
var c = new OtherSomeClass();
>> Не знаю, как часто вы используете LINQ, но от таких штук лучше отказаться.
Не согласен с вами. Каждому случаю свои ограничения.
Не использование Linq почти всегда раздувает код и снижает читаемость.

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

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

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

З.Ы. Так же поставил вам лайк в гринлайте за эту статью, надеюсь игру пропустят.
Спасибо) «Так же хотелось бы увидеть как потом связать вашу бизнес-логику с приложением в Unity» — вот это будет еще нескоро. Потому пока напишу тут. В View части при рендеринге я просто использую данные из модели. Выполнение ж каждой команды паблишит событие. Что-то вроде такого:

public abstract class Command
{
    public Core Core { get; private set; }
    public bool IsValid { get; private set; }

    public Command Execute (Core core)
    {
        Core = core;
        IsValid = Run();
        if (isValid) core.Publish(this); /// HERE
        return this;
    }

    protected abstract bool Run ();
}


Потом я подписываюсь на эти события и перерисовываю то, что могло измениться

Пару примеров из реального кода:

public void On (MemberWakeup ev)
{
	UpdateRowByMember(ev.member);
}

public void On (MemberReject ev)
{
	UpdateRowByMember(ev.member);
}

public void On (MemberAssign ev)
{
	UpdateRowByMember(ev.member);
}


public void On (ModuleEnable ev)
{
	if (ev.module == module) Render();
}

public void On (ModuleDisable ev)
{
	if (ev.module == module) Render();
}


public void On (ResourceChangeEvent ev)
{
	if (activeTreeLeaf != null) {
		activeTreeLeaf.RenderPanel();
	}
}


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

public class ModuleOption
{
	public void On (ResourceChangeEvent ev)
	{
		UpdateCost();
	}


Когда открыто окно постройки модуля при изменении цены перерисуются только цены (под перерисовкой я понимаю не реально стирание и новое рисование, а указание новых значений, а уже юнити потом думает — рисовать заново или что там ему делать)
TheShock игра понравилась, проголосовал за неё на greenlight. Но подскажи такой момент: у меня закончились свободные комнаты и я не могу построить помещение, необходимое для исследований, чтобы проапгрейдить корабль. Есть какая-то возможность заменить существующую комнату? А то я в тупике.
В очередной раз прочитал вашу статью, и появилось несколько вопросов. (скорее нубские вопросы, но я только начанаю изучать Unity3d и C#)
мне до сих пор не понятно как вы в дальнейшем подключаете свою логику к проекту в unity?
компилите отдельное DLL и просто его добавляете в проект? или переносите весь код в папку Assets? а потом что? если я правильно понимаю, то чтобы у вас все работало, необходимо создать экземпляр класса Core, который и будет точкой входа основной логики. на что вы его вешаете? на некий пустой объект, который будет у вас присутствовать в каждой сцене?
а как тогда будут работать остальные объекты в сцене, ведь у них не будет ссылки на экземпляр Core и связанных с ним объектов…
вопрос номер два: наткнулся в доке по Unity на Test Runner, который в последней версии даже встроен в сам движок. https://docs.unity3d.com/Manual/testing-editortestsrunner.html есть ли смысл тесты сразу под него писать?

P.S. Извиняюсь за свои тупые вопросы.

P.S.S. Поздравляю с успешным прохождением Green Light.
Спасибо. Вопросы не тупые, вполне себе логичные. Я сейчас все пишу в одном проекте, тесты можно запустить из другого проекта в том же солюшине

Да, у меня есть некий пустой объект, который является точкой входа и создает Engine.Core когда игрок начинает «Начать игру» или «Загрузить игру».

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

class App : GameObject
{
  public Engine.Core Game { get; private set; }

  public void StartNewGame () {
    Game = new Engine.Core();
  }
}


class Foo : GameObject
  , IListener<ResourceChangeEvent>
{
  private BlaBla blabla;

  public void Awake () {
    App.Game.Emitter.Subscribe(this);
  }

  public void On (ResourceChangeEvent ev)
  {
    if (blabla != null) {
      blabla.Do();
    }
  }
}


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

Unity тестами, к сожалению, не пользовался
Sign up to leave a comment.

Articles

Change theme settings