[Программирование, Проектирование и рефакторинг, Разработка игр] Как мы пришли к реактивному связыванию в Unity3D
Автор
Сообщение
news_bot ®
Стаж: 6 лет 9 месяцев
Сообщений: 27286
Сегодня я расскажу о том, как некоторые проекты в Pixonic пришли к тому, что для всего мирового фронтэнда уже давно стало нормой, — к реактивному связыванию.
Подавляющее большинство наших проектов пишется на Unity 3D. И, если у других клиентских технологий с реактивщиной всё неплохо (MVVM, Qt, миллионы JS-фреймворков), и воспринимается она как должное, в Unity каких-либо встроенных или общепринятых средств связывания нет.
У кого-то к этому моменту наверняка созрел вопрос: «А зачем? Мы такое не используем и неплохо живём».
Причины были. Точнее, были проблемы, одним из решений которых могло стать использование такого подхода. В результате оно им стало. А подробности под катом.
Сначала о проекте, проблемы которого и потребовали такого решения. Конечно же, речь о War Robots — гигантском проекте с множеством различных команд разработки, поддержки, маркетинга и т. д. Нас сейчас интересуют только две из них: команда клиентских программистов и команда пользовательского интерфейса. Далее для простоты будем называть их «код» и «вёрстка». Так уж сложилось, что проектированием и вёрсткой UI у нас занимаются одни люди, а «оживлением» всего этого — другие. Это логично, и на своём опыте я встречал немало подобных примеров организации команд.
Мы заметили, что при растущем потоке фичей на проекте взаимодействие кода и вёрстки становится местом взаимных блокировок и «узким горлышком». Программисты ждут готовых виджетов для работы, верстальщики — каких-то доработок от кода. Да много всего происходило при этом взаимодействии. Словом, иногда это превращалось в хаос и прокрастинацию.
Сейчас поясню. Взгляните на классический простой пример виджета — особенно на метод RefreshData. Остальной бойлерплейт я просто добавил для правдоподобия, и он не стоит особого внимания.
public class PlayerProfileWidget : WidgetBehaviour
{
[SerializeField] private Text nickname;
[SerializeField] private Image avatar;
[SerializeField] private Text level;
[SerializeField] private GameObject hasUpgradeMark;
[SerializeField] private Button upgradeButton;
public void Initialize(ProfileService profileService)
{
RefreshData(profileService.Player);
upgradeButton.onClick
.Subscribe(profileService.UpgradePlayer)
.DisposeWith(Lifetime);
profileService.PlayerUpgraded
.Subscribe(RefreshData)
.DisposeWith(Lifetime);
}
private void RefreshData(in PlayerModel player)
{
nickname.text = player.Id;
avatar.overrideSprite = Resources.Load<Sprite>($"Avatars/{player.Avatar}_Small");
level.text = player.Level.ToString();
hasUpgradeMark.SetActive(player.HasUpgrade);
}
}
Это пример статического связывания «сверху вниз». В компонент верхнего (по иерархии) GameObject’а вы линкуете компоненты соответствующих типов нижних объектов. Тут всё предельно просто, но не очень гибко.
Функциональность виджетов расширяется постоянно с приходом новых фичей. Давайте представим. Вокруг аватара теперь должна быть рамка, вид которой зависит от уровня игрока. Окей, добавим ссылку на Image рамки и будем туда погружать соответствующий уровню спрайт, затем добавим настройку соответствия уровня и рамки и отдадим все это вёрстке. Готово.
Прошёл месяц. Теперь в виджете игрока появляется иконка клана, если он состоит в таковом. А ещё нужно прописать звание, которое он там имеет. И никнейм нужно покрасить в зелёный цвет, если есть апгрейд. Вдобавок, мы теперь используем TextMeshPro. А ещё…
Ну, вы поняли. Кода становится всё больше, он становится сложнее и сложнее, обрастая различными условиями.
Вариантов работы здесь несколько. Например, программист модифицирует код виджета, отдаёт изменения вёрстке. Там довёрстывают и линкуют компоненты в новые поля. Или наоборот: вёрстка может подоспеть заранее, программист сам прилинкует всё, что будет необходимо. Обычно потом происходит ещё несколько итераций исправлений. В любом случае, этот процесс не параллельный. Оба участника работают над одним ресурсом. А мержить префабы или сцены — то ещё удовольствие.
У инженеров всё просто: если видишь проблему, пытаешься её решить. Вот мы и пытались. В результате пришли к идее, что нужно сужать фронт соприкосновения двух команд. А реактивные паттерны сужают этот фронт до одной точки — того, что обычно называют View Model. Для нас она выступает в роли контракта между кодом и вёрсткой. Когда я перейду к деталям, станет ясен смысл контракта, и почему он не блокирует параллельную работу двух команд.
На тот момент, когда мы только задумались обо всём этом, существовало несколько сторонних решений. Мы смотрели в сторону Unity Weld, Peppermint Data Binding, DisplayFab. У всех были свои плюсы и минусы. Но один из фатальных для нас недостатков был общим — слабая для наших целей производительность. На простых интерфейсах они, может, и нормально работают, но к тому моменту нам сложности интерфейсов избежать не удалось.
Поскольку задача не представлялась запредельно сложной, да ещё и релевантный опыт имелся, было решено реализовать систему реактивного связывания внутри студии.
Задачи были такие:
- Производительность. Сам механизм распространения изменений должен быть быстрым. Ещё желательно уменьшить нагрузку на GC, чтобы можно было использовать это всё даже в геймплее, где совсем не рады фризам.
- Удобный авторинг. Это нужно для того, чтобы с системой могли работать ребята из команды UI.
- Удобный API.
- Расширяемость.
«Сверху вниз», или общее описание
Задача понятна, цели ясны. Начнём с «контракта» — ViewModel. Его должен уметь формировать любой человек, а значит, реализовать ViewModel нужно максимально просто. По сути это просто набор свойств, которые определяют текущее состояние отображения.
Для простоты набор типов свойств со значениями мы максимально ограничили до bool, int, float и string. Это было продиктовано сразу несколькими соображениями:
- Сериализация этих типов в Unity не требует никаких усилий;
- Это подмножество типов, которыми пользуется и бизнес-логика, и отображение. К примеру, вам не нужен тип Sprite в бизнес-логике, так же как и пользовательский тип PlayerModel в чистом виде сложно прикрутить в отображении, даже если он у вас прекрасно сериализуется;
- Подобные ограничения делают проще реализацию, особенно когда вам нужно писать код для авторинга системы и инструменты редактирования.
Все свойства активны и сообщают подписчикам об изменениях своих значений. Не всегда эти значения есть — бывают просто события в бизнес-логике, которые нужно как-то визуализировать. На этот случай есть тип свойства без значения — event.
Без коллекций, конечно же, в интерфейсах тоже никак не обойтись. Поэтому есть и тип свойства collection. Коллекция оповещает подписчиков о любом изменении своего состава. Элементы коллекции — это тоже ViewModel определённой структуры или схемы. Эта схема тоже описывается в контракте при редактировании.
В редакторе ViewModel выглядит следующим образом:
Стоит обратить внимание, что свойства можно редактировать прямо в инспекторе и «на лету». Это позволяет посмотреть, как будет вести себя виджет (или окно, или сцена, или что угодно) в рантайме даже без кода, что на практике очень удобно.
Если ViewModel — верх нашей системы связывания, то низ — так называемые аппликаторы. Это конечные подписчики свойств ViewModel, которые как раз и делают всю работу:
- Включают/выключают GameObject или отдельные компоненты по изменению значения булевого свойства;
- Меняют текст в поле зависимости от значения строкового свойства;
- Запускают аниматор, меняют его параметры;
- Подставляют нужный спрайт из коллекции по индексу или строковому ключу.
На этом я остановлюсь, так как количество вариантов применения ограничено только фантазией и спектром задач, которые вы решаете.
Вот так выглядят некоторые аппликаторы в редакторе:
Для большей гибкости между свойствами и аппликаторами можно использовать адаптеры. Это сущности для преобразования свойств перед применениями. Их тоже много разных:
- Логические — например, когда вам нужно инвертировать булевое свойство или выдавать true или false в зависимости от значения другого типа (хочу золотую рамку, когда уровень выше 15).
- Арифметические. Тут без комментариев.
- Операции над коллекциями: инвертировать, взять только часть коллекции, сортировать по ключу и многое другое.
Опять же, различных вариантов адаптеров может быть великое множество, так что не буду продолжать.
На деле же, пусть общее количество различных аппликаторов и адаптеров большое, базовый, используемый повсеместно набор весьма ограничен. Человеку, работающему с контентом, нужно предварительно изучить этот набор, что несколько увеличивает время обучения. Впрочем, нужно один раз уделить этому время, чтобы далее здесь не возникало больших проблем. Тем более, что у нас есть кукбук и документация на этот счёт.
Когда вёрстке чего-то нехватает, программисты дописывают необходимые компоненты. При этом подавляющая часть аппликаторов и адаптеров универсальна и активно используется повторно. Отдельно стоит заметить, что у нас всё же есть аппликаторы, работающие на рефлексии через UnityEvent. Они применимы в случаях, когда нужный аппликатор ещё не реализован или его реализация нецелесообразна.
Работы команде вёрстки это, несомненно, добавляет. Но в нашем случае они даже рады той степени свободы и независимости от программистов, которую получают. И если со стороны вёрстки работы прибавилось, то со стороны кода теперь всё намного проще.
Вернёмся к примеру с PlayerProfileWidget. Вот так он теперь выглядит в нашем гипотетическом проекте в виде презентера, ведь тут больше не нужен Widget в виде компонента, и мы можем всё получить из ViewModel вместо линковки всего напрямую:
public class PlayerProfilePresenter : Presenter
{
private readonly IMutableProperty<string> _playerId;
private readonly IMutableProperty<string> _playerAvatar;
private readonly IMutableProperty<int> _playerLevel;
private readonly IMutableProperty<bool> _playerHasUpgrade;
public PlayerProfilePresenter(ProfileService profileService, IViewModel viewModel)
{
_playerId = viewModel.GetString("player/id");
_playerAvatar = viewModel.GetString("player/avatar");
_playerLevel = viewModel.GetInteger("player/level");
_playerHasUpgrade = viewModel.GetBoolean("player/has-upgrade");
RefreshData(profileService.Player);
viewModel.GetEvent("player/upgrade")
.Subscribe(profileService.UpgradePlayer)
.DisposeWith(Lifetime);
profileService.PlayerUpgraded
.Subscribe(RefreshData)
.DisposeWith(Lifetime);
}
private void RefreshData(in PlayerModel player)
{
_playerId.Value = player.Id;
_playerAvatar.Value = player.Avatar;
_playerLevel.Value = player.Level;
_playerHasUpgrade.Value = player.HasUpgrade;
}
}
В конструкторе можно увидеть получение кодом свойств из ViewModel. Да, в этом коде для простоты опущены проверки, но существуют методы, которые кинут исключение, если не найдут нужного свойства. Кроме того, у нас есть несколько инструментов, которые дают довольно сильную гарантию присутствия нужных полей. Они основаны на валидации ассетов, о которой можно почитать тут.
Я не буду вдаваться в детали реализации, так как это займёт ещё очень много текста и вашего времени. Если будет общественный запрос, то лучше будет это оформить отдельной статьёй. Скажу только, что реализация не сильно отличается от того же Rx, только всё немного проще.
В таблице приведены результаты бенчмарка, в котором происходит создание 500 форм с InputField, Text и Button, связанных с одним проперти модели и одной функцией действия.
В качестве вывода могу сообщить, что озвученные выше цели были достигнуты. Сравнительные бенчмарки показывают выигрыш как по памяти, так и по времени относительно упомянутых вариантов. По мере вникания команды вёрстки и людей из других отделов, которые занимаются контентом, трений и блокировок становится всё меньше. Эффективность и качество кода возросли, и теперь многие вещи не требуют вмешательства программистов.
===========
Источник:
habr.com
===========
Похожие новости:
- [JavaScript, Разработка игр, TypeScript, Логические игры] DagazServer: Как всё устроено
- [Анализ и проектирование систем, Программирование, Проектирование и рефакторинг, Управление персоналом, Управление разработкой] Человечная декомпозиция работы
- [Тестирование IT-систем, Программирование, Тестирование веб-сервисов] Как мы «разогнали» команду QA, и что из этого получилось
- [DIY или Сделай сам, Программирование микроконтроллеров] Дистанционное управление громкостью IP TV приставки при помощи Attiny13A
- [Высокая производительность, Python, Программирование, Машинное обучение] Deep Learning Inference Benchmark — измеряем скорость работы моделей глубокого обучения
- [Git, Программирование, Системы управления версиями] И полгода не прошло: выпущена система управления версиями Git 2.29
- [Разработка игр, Производство и разработка электроники, Компьютерное железо, Игры и игровые приставки] Ажиотажный спрос на новые карты Nvidia — заслуга не производителя, а косоруких разработчиков игр
- [Программирование, Scala] Scala как первый язык
- [Разработка игр, Развитие стартапа, Разработка мобильных приложений, Продвижение игр] Финляндия для разработчиков игр: маленькая страна с большими возможностями
- [Разработка веб-сайтов, JavaScript, Программирование] Веб-компоненты: руководство для начинающих (перевод)
Теги для поиска: #_programmirovanie (Программирование), #_proektirovanie_i_refaktoring (Проектирование и рефакторинг), #_razrabotka_igr (Разработка игр), #_reaktivnoe_programmirovanie (реактивное программирование), #_reactive_programming, #_reaktivnost (реактивность), #_gamedev, #_razrabotka_igr (разработка игр), #_unity3d, #_blog_kompanii_pixonic (
Блог компании Pixonic
), #_programmirovanie (
Программирование
), #_proektirovanie_i_refaktoring (
Проектирование и рефакторинг
), #_razrabotka_igr (
Разработка игр
)
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 13:14
Часовой пояс: UTC + 5
Автор | Сообщение |
---|---|
news_bot ®
Стаж: 6 лет 9 месяцев |
|
Сегодня я расскажу о том, как некоторые проекты в Pixonic пришли к тому, что для всего мирового фронтэнда уже давно стало нормой, — к реактивному связыванию. Подавляющее большинство наших проектов пишется на Unity 3D. И, если у других клиентских технологий с реактивщиной всё неплохо (MVVM, Qt, миллионы JS-фреймворков), и воспринимается она как должное, в Unity каких-либо встроенных или общепринятых средств связывания нет. У кого-то к этому моменту наверняка созрел вопрос: «А зачем? Мы такое не используем и неплохо живём». Причины были. Точнее, были проблемы, одним из решений которых могло стать использование такого подхода. В результате оно им стало. А подробности под катом. Сначала о проекте, проблемы которого и потребовали такого решения. Конечно же, речь о War Robots — гигантском проекте с множеством различных команд разработки, поддержки, маркетинга и т. д. Нас сейчас интересуют только две из них: команда клиентских программистов и команда пользовательского интерфейса. Далее для простоты будем называть их «код» и «вёрстка». Так уж сложилось, что проектированием и вёрсткой UI у нас занимаются одни люди, а «оживлением» всего этого — другие. Это логично, и на своём опыте я встречал немало подобных примеров организации команд. Мы заметили, что при растущем потоке фичей на проекте взаимодействие кода и вёрстки становится местом взаимных блокировок и «узким горлышком». Программисты ждут готовых виджетов для работы, верстальщики — каких-то доработок от кода. Да много всего происходило при этом взаимодействии. Словом, иногда это превращалось в хаос и прокрастинацию. Сейчас поясню. Взгляните на классический простой пример виджета — особенно на метод RefreshData. Остальной бойлерплейт я просто добавил для правдоподобия, и он не стоит особого внимания. public class PlayerProfileWidget : WidgetBehaviour
{ [SerializeField] private Text nickname; [SerializeField] private Image avatar; [SerializeField] private Text level; [SerializeField] private GameObject hasUpgradeMark; [SerializeField] private Button upgradeButton; public void Initialize(ProfileService profileService) { RefreshData(profileService.Player); upgradeButton.onClick .Subscribe(profileService.UpgradePlayer) .DisposeWith(Lifetime); profileService.PlayerUpgraded .Subscribe(RefreshData) .DisposeWith(Lifetime); } private void RefreshData(in PlayerModel player) { nickname.text = player.Id; avatar.overrideSprite = Resources.Load<Sprite>($"Avatars/{player.Avatar}_Small"); level.text = player.Level.ToString(); hasUpgradeMark.SetActive(player.HasUpgrade); } } Это пример статического связывания «сверху вниз». В компонент верхнего (по иерархии) GameObject’а вы линкуете компоненты соответствующих типов нижних объектов. Тут всё предельно просто, но не очень гибко. Функциональность виджетов расширяется постоянно с приходом новых фичей. Давайте представим. Вокруг аватара теперь должна быть рамка, вид которой зависит от уровня игрока. Окей, добавим ссылку на Image рамки и будем туда погружать соответствующий уровню спрайт, затем добавим настройку соответствия уровня и рамки и отдадим все это вёрстке. Готово. Прошёл месяц. Теперь в виджете игрока появляется иконка клана, если он состоит в таковом. А ещё нужно прописать звание, которое он там имеет. И никнейм нужно покрасить в зелёный цвет, если есть апгрейд. Вдобавок, мы теперь используем TextMeshPro. А ещё… Ну, вы поняли. Кода становится всё больше, он становится сложнее и сложнее, обрастая различными условиями. Вариантов работы здесь несколько. Например, программист модифицирует код виджета, отдаёт изменения вёрстке. Там довёрстывают и линкуют компоненты в новые поля. Или наоборот: вёрстка может подоспеть заранее, программист сам прилинкует всё, что будет необходимо. Обычно потом происходит ещё несколько итераций исправлений. В любом случае, этот процесс не параллельный. Оба участника работают над одним ресурсом. А мержить префабы или сцены — то ещё удовольствие. У инженеров всё просто: если видишь проблему, пытаешься её решить. Вот мы и пытались. В результате пришли к идее, что нужно сужать фронт соприкосновения двух команд. А реактивные паттерны сужают этот фронт до одной точки — того, что обычно называют View Model. Для нас она выступает в роли контракта между кодом и вёрсткой. Когда я перейду к деталям, станет ясен смысл контракта, и почему он не блокирует параллельную работу двух команд. На тот момент, когда мы только задумались обо всём этом, существовало несколько сторонних решений. Мы смотрели в сторону Unity Weld, Peppermint Data Binding, DisplayFab. У всех были свои плюсы и минусы. Но один из фатальных для нас недостатков был общим — слабая для наших целей производительность. На простых интерфейсах они, может, и нормально работают, но к тому моменту нам сложности интерфейсов избежать не удалось. Поскольку задача не представлялась запредельно сложной, да ещё и релевантный опыт имелся, было решено реализовать систему реактивного связывания внутри студии. Задачи были такие:
«Сверху вниз», или общее описание Задача понятна, цели ясны. Начнём с «контракта» — ViewModel. Его должен уметь формировать любой человек, а значит, реализовать ViewModel нужно максимально просто. По сути это просто набор свойств, которые определяют текущее состояние отображения. Для простоты набор типов свойств со значениями мы максимально ограничили до bool, int, float и string. Это было продиктовано сразу несколькими соображениями:
Все свойства активны и сообщают подписчикам об изменениях своих значений. Не всегда эти значения есть — бывают просто события в бизнес-логике, которые нужно как-то визуализировать. На этот случай есть тип свойства без значения — event. Без коллекций, конечно же, в интерфейсах тоже никак не обойтись. Поэтому есть и тип свойства collection. Коллекция оповещает подписчиков о любом изменении своего состава. Элементы коллекции — это тоже ViewModel определённой структуры или схемы. Эта схема тоже описывается в контракте при редактировании. В редакторе ViewModel выглядит следующим образом: Стоит обратить внимание, что свойства можно редактировать прямо в инспекторе и «на лету». Это позволяет посмотреть, как будет вести себя виджет (или окно, или сцена, или что угодно) в рантайме даже без кода, что на практике очень удобно. Если ViewModel — верх нашей системы связывания, то низ — так называемые аппликаторы. Это конечные подписчики свойств ViewModel, которые как раз и делают всю работу:
На этом я остановлюсь, так как количество вариантов применения ограничено только фантазией и спектром задач, которые вы решаете. Вот так выглядят некоторые аппликаторы в редакторе: Для большей гибкости между свойствами и аппликаторами можно использовать адаптеры. Это сущности для преобразования свойств перед применениями. Их тоже много разных:
Опять же, различных вариантов адаптеров может быть великое множество, так что не буду продолжать. На деле же, пусть общее количество различных аппликаторов и адаптеров большое, базовый, используемый повсеместно набор весьма ограничен. Человеку, работающему с контентом, нужно предварительно изучить этот набор, что несколько увеличивает время обучения. Впрочем, нужно один раз уделить этому время, чтобы далее здесь не возникало больших проблем. Тем более, что у нас есть кукбук и документация на этот счёт. Когда вёрстке чего-то нехватает, программисты дописывают необходимые компоненты. При этом подавляющая часть аппликаторов и адаптеров универсальна и активно используется повторно. Отдельно стоит заметить, что у нас всё же есть аппликаторы, работающие на рефлексии через UnityEvent. Они применимы в случаях, когда нужный аппликатор ещё не реализован или его реализация нецелесообразна. Работы команде вёрстки это, несомненно, добавляет. Но в нашем случае они даже рады той степени свободы и независимости от программистов, которую получают. И если со стороны вёрстки работы прибавилось, то со стороны кода теперь всё намного проще. Вернёмся к примеру с PlayerProfileWidget. Вот так он теперь выглядит в нашем гипотетическом проекте в виде презентера, ведь тут больше не нужен Widget в виде компонента, и мы можем всё получить из ViewModel вместо линковки всего напрямую: public class PlayerProfilePresenter : Presenter
{ private readonly IMutableProperty<string> _playerId; private readonly IMutableProperty<string> _playerAvatar; private readonly IMutableProperty<int> _playerLevel; private readonly IMutableProperty<bool> _playerHasUpgrade; public PlayerProfilePresenter(ProfileService profileService, IViewModel viewModel) { _playerId = viewModel.GetString("player/id"); _playerAvatar = viewModel.GetString("player/avatar"); _playerLevel = viewModel.GetInteger("player/level"); _playerHasUpgrade = viewModel.GetBoolean("player/has-upgrade"); RefreshData(profileService.Player); viewModel.GetEvent("player/upgrade") .Subscribe(profileService.UpgradePlayer) .DisposeWith(Lifetime); profileService.PlayerUpgraded .Subscribe(RefreshData) .DisposeWith(Lifetime); } private void RefreshData(in PlayerModel player) { _playerId.Value = player.Id; _playerAvatar.Value = player.Avatar; _playerLevel.Value = player.Level; _playerHasUpgrade.Value = player.HasUpgrade; } } В конструкторе можно увидеть получение кодом свойств из ViewModel. Да, в этом коде для простоты опущены проверки, но существуют методы, которые кинут исключение, если не найдут нужного свойства. Кроме того, у нас есть несколько инструментов, которые дают довольно сильную гарантию присутствия нужных полей. Они основаны на валидации ассетов, о которой можно почитать тут. Я не буду вдаваться в детали реализации, так как это займёт ещё очень много текста и вашего времени. Если будет общественный запрос, то лучше будет это оформить отдельной статьёй. Скажу только, что реализация не сильно отличается от того же Rx, только всё немного проще. В таблице приведены результаты бенчмарка, в котором происходит создание 500 форм с InputField, Text и Button, связанных с одним проперти модели и одной функцией действия. В качестве вывода могу сообщить, что озвученные выше цели были достигнуты. Сравнительные бенчмарки показывают выигрыш как по памяти, так и по времени относительно упомянутых вариантов. По мере вникания команды вёрстки и людей из других отделов, которые занимаются контентом, трений и блокировок становится всё меньше. Эффективность и качество кода возросли, и теперь многие вещи не требуют вмешательства программистов. =========== Источник: habr.com =========== Похожие новости:
Блог компании Pixonic ), #_programmirovanie ( Программирование ), #_proektirovanie_i_refaktoring ( Проектирование и рефакторинг ), #_razrabotka_igr ( Разработка игр ) |
|
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 13:14
Часовой пояс: UTC + 5