Хабрахабр

Расширяем возможности UObject в Unreal Engine 4

Меня зовут Александр, я уже более 5 лет работаю с Unreal Engine, и почти все это время — с сетевыми проектами. Всем привет!

В этой статье я расскажу о том, как активировать различные функции в базовом классе UObject в Unreal Engine 4. Поскольку сетевые проекты отличаются своими требованиями к разработке и производительности, нередко необходимо работать с более простыми объектами, такими как классы UObject, но их функциональность изначально урезана, что может создать сильные рамки.

Большую часть информации крайне сложно найти в документации или сообществе, а тут можно быстро открыть ссылку и скопировать нужный код. На самом деле, статью я написал скорее как справочник. Статья ориентирована на тех, кто уже немного знаком с UE4. Решил заодно поделиться и с вами! Можете просто следовать инструкциям, если вам нужно то, о чем пойдет речь. Будет рассмотрен С++ код, хотя знать его не обязательно. Более того, не обязательно копировать все, вы можете вставить код из раздела с нужными свойствами и он должен работать.

Немного о UObject

UObject — базовый класс почти для всего, что есть в Unreal Engine 4. От него наследуется подавляющее большинство объектов, которые создаются у вас в мире или просто в памяти: объекты на сцене (AActor), компоненты (UActorComponent), разные типы для работы с данными и прочие.

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

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

В общем-то, примеров использования масса. Для чего мне UObject, если AActor уже поддерживает все, что нужно? На сцене, где-то в небе, хранить их нецелесообразно, поэтому можно хранить в памяти, не нагружая рендер и не создавая лишних свойств. Самый простой — предметы для инвентаря. Для тех, кто любит технические сравнения, то AActor занимает килобайт (1016 байт), а пустой UObject всего 56 байт.

В чем проблема UObject?

Проблем в целом нет, ну или я просто не сталкивался с ними. Все, чем раздражает UObject, так это отсутствие различных возможностей, которые по умолчанию доступны в AActor или в компонентах. Вот проблемы, которые я выделил за свою практику:

  • UObject’ы не реплицируются по сети;
  • из-за первого пункта мы не можем вызывать RPC события;
  • нельзя использовать обширный набор функций, требующих ссылку на мир в Блупринтах;
  • в них нет стандартных событий вроде BeginPlay и Tick;
  • нельзя добавлять компоненты из UObject’ов в AActor в Блупринтах.

А вот с некоторыми придется повозиться. Большую часть вещей можно легко решить.

Создание UObject

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

Создать новый класс мы можем в Content Browser редактора, нажав кнопку New и выбрав там пункт New C++ Class.

В общем списке его может не быть, поэтому раскрываем его и выбираем UObject. Далее нам нужно выбрать сам класс.

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

В .h вы будете объявлять переменные и функции, а в .cpp определять их логику. Новички, обратите внимание, что создается два файла: .h и .ccp. Если не меняли путь, то они должны быть в Project/Source/Project/. Найдите оба файла в вашем проекте.

Должно получиться что-то вроде этого: Пока мы не продолжили, давайте в макросе UCLASS() над объявлением класса пропишем параметр Blueprintable.

.h

UCLASS(Blueprintable)
class MYPROPJECT_API UMyObject : public UObject
{ GENERATED_BODY()
}

Благодаря этому можно создавать Блупринты, которые будут наследовать все, что мы сделаем с этим объектом.

Репликация UObject

По умолчанию UObject’ы не реплицируются по сети. Как я описал выше, создается ряд ограничений, когда нужно синхронизировать данные или логику между сторонами, но при этом не хранить мусор в мире.

Значит просто создать объект в памяти и отреплицировать его никак не получится. В Unreal Engine 4 репликация проходит как раз за счет мировых объектов. Например, если ваш объект — это навык персонажа, то владельцем должен стать сам персонаж. Вам в любом случае понадобится владелец, который будет управлять передачей данных объекта между сервером и клиентами. Он же и будет проводником для передачи информации по сети.

Пока в хедере нам нужно задать лишь одну функцию: Подготовим наш объект к репликации.

.h

UCLASS(Blueprintable)
class MYPROPJECT_API UMyObject : public UObject
{ GENERATED_BODY()
public: virtual bool IsSupportedForNetworking () const override ; }

IsSupportedForNetworking() определит, что объект поддерживает сеть и может быть отреплицирован.

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

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

Внутри нам нужны 3 функции: конструктор, функция репликации подобъектов, функция, определяющая, что внутри этого AActor реплицируется (переменные, ссылки на объекты и прочее) и место, где мы создадим наш объект.

Не забудем создать и переменную, по которой будет храниться наш объект:

.h

class MYPROPJECT_API AMyActor : public AActor
{ GENERATED_BODY() public: AMyActor(); virtual bool ReplicateSubobjects (class UActorChannel *Channel, class FOutBunch *Bunch, FReplicationFlags *RepFlags) override; void GetLifetimeReplicatedProps (TArray<FLifetimeProperty>& OutLifetimeProps) const override; virtual void BeginPlay (); UPROPERTY(Replicated, BlueprintReadOnly, Category="Object") class UMyObject* MyObject;
}

Внутри исходного файла мы должны все прописать:

.cpp

//Необходимые инклуды
#include "MyActor.h"
#include "Net/UnrealNetwork.h"
#include "Engine/World.h"
#include "Engine/ActorChannel.h"
#include "путь до вашего UObject/MyObject.h" AMyActor::AMyActor()
{ //Реплицировать наш Actor по сети. bReplicates = true //Радиус репликации. NetCullDistanceSquared = 99999; //Частота репликации (раз в секунду). NetUpdateFrequency = 1.f;
} void AMyActor::GetLifetimeReplicatedProps (TArray<FLifetimeProperty>& OutLifetimeProps)
{ Super::GetLifetimeReplicatedProps(OutLifetimeProps); // Помечаем ссылку на наш объект на репликацию. Без репликации ссылки вы не сможете достать свой объект на клиенте. DOREPLIFETIME(AMyActor, MyObject);
} bool AMyActor::ReplicateSubobjects(UActorChannel * Channel, FOutBunch * Bunch, FReplicationFlags * RepFlags)
{ bool WroteSomething = Super::ReplicateSubobjects(Channel, Bunch, RepFlags); // Реплицируем наш объект. if (MyObject ) WroteSomething |= Channel->ReplicateSubobject(MyObject , *Bunch, *RepFlags); return WroteSomething;
} AMyActor::BeginPlay()
{ /* Создадим наш объект при старте уровня (или спауне объекта) на сервере. Обратите внимание на this. В качестве параметра передается ссылка на владельца объектом. Важно, чтобы владелец был со всей нужной логикой, иначе объект реплицироваться не будет. */ if(HasAuthority()) { MyObject = NewObject<UMyObject>(this); // Напишем в лог имя созданного объекта if(MyObject) UE_LOG(LogTemp, Log, TEXT("%s created"), *MyObject->GetName()); }
}

Вы можете вывести его имя на тик, но уже на клиенте. Теперь ваш объект будет реплицироваться вместе с этим Actor’ом. Обратите внимание, что на Begin Play объект до клиента вряд ли успеет прийти, поэтому там смысла писать лог на нем нет.

Репликация переменных в UObject

В большинстве случаях реплицировать объект смысла нет, если в нем не содержится информация, которая так же будет синхронизироваться между сервером и клиентами. Поскольку наш объект уже реплицируется, то и передавать переменные не составит труда. Это делается так же, как и внутри нашего Actor’а:

.h

UCLASS(Blueprintable)
class MYPROPJECT_API UMyObject : public UObject
{ GENERATED_BODY()
public: virtual bool IsSupportedForNetworking () const override { return true; }; void GetLifetimeReplicatedProps (TArray<FLifetimeProperty>& OutLifetimeProps) const override; UPROPERTY(Replicated, BlueprintReadWrite, Category="Object") int MyInteger; // Остальной код }

.cpp

//Необходимые инклуды
#include "MyObject.h"
#include "Net/UnrealNetwork.h" UMyObject ::UMyObject ()
{ //Реплицировать наш Object по сети. Тоже это нужно указать тут. bReplicates = true //Радиус репликации и частота репликации наследуются от владельца, который отвечает за репликацию объекта.
} void UMyObject ::GetLifetimeReplicatedProps (TArray<FLifetimeProperty>& OutLifetimeProps)
{ Super::GetLifetimeReplicatedProps(OutLifetimeProps); // Помечаем наш Integer на репликацию. DOREPLIFETIME(UMyObject, MyInteger);
} }

Тут все просто и так же, как в AActor. Добавив переменную и пометив на репликацию, мы сможем ее реплицировать.

Это будет особенно заметно, если вы создаете ваш UObject не для работы в C++, а подготавливаете его для наследования и работы в Блупринтах. Однако есть небольшой подводный камень, который виден не сразу, но может ввести вас в заблуждение.

Движок автоматически их не помечает и изменение параметра на сервере в БП ничего не меняет в значении на клиенте. Суть в том, что переменные, созданные в наследнике в Блупринтах, реплицироваться не будут. Для корректной репликации переменных БП вам нужно заранее их пометить. Но и от этого есть лекарство. Добавьте пару строчек в GetLifetimeReplicatedProps():

.cpp

void UMyObject ::GetLifetimeReplicatedProps (TArray<FLifetimeProperty>& OutLifetimeProps)
{ Super::GetLifetimeReplicatedProps(OutLifetimeProps); // Помечаем наш Integer на репликацию. DOREPLIFETIME(UMyObject, MyInteger); // Помечаем переменные из Блупринтов на репликацию UBlueprintGeneratedClass* BPClass = Cast<UBlueprintGeneratedClass>(GetClass()); if (BPClass) BPClass->GetLifetimeBlueprintReplicationList(OutLifetimeProps); }

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

RPC события в UObject

RPC (Remote Procedure Call) события — это специальные функции, вызывающиеся на другой стороне сетевого взаимодействия проекта. С помощью них вы можете вызвать функцию с сервера на других клиентах и с клиента на сервере. Очень полезно и часто используется при написании сетевых проектов.

В нем описывается использование в C++ и в Блупринтах. Если вы не знакомы с ними, рекомендую почитать один материал.

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

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

.h


//Добавляем нужный инклуд
#include "Engine/EngineTypes.h" UCLASS(Blueprintable)
class MYPROPJECT_API UMyObject : public UObject
{ GENERATED_BODY()
public: virtual bool CallRemoteFunction (UFunction * Function, void * Parms, struct FOutParmRec * OutParms, FFrame * Stack) override; virtual int32 GetFunctionCallspace (UFunction* Function, void* Parameters, FFrame* Stack) override; // Остальной код }

.cpp

//Добавляем необходимые инклуды
#include "Engine/NetDriver.h" // Вызываем удаленную функцию и ставим дополнительные проверки.
bool UMyObject::CallRemoteFunction(UFunction * Function, void * Parms, FOutParmRec * OutParms, FFrame * Stack)
{ if (!GetOuter()) return false; UNetDriver* NetDriver = GetOuter()->GetNetDriver(); if (!NetDriver) return false; NetDriver->ProcessRemoteFunction(GetOuter(), Function, Parms, OutParms, Stack, this); return true;
} int32 UMyObject::GetFunctionCallspace(UFunction * Function, void * Parameters, FFrame * Stack)
{ return (GetOuter() ? GetOuter()->GetFunctionCallspace(Function, Parameters, Stack) : FunctionCallspace::Local);
}

С этими функциями мы сможем вызывать RPC события не только в коде, но и в Блупринтах.

Например объектом владеет персонаж пользователя или же предмет, у которого Owner — это Player Controller игрока. Обратите внимание, что для вызова Client или Server событий необходим владелец, у которого Owner — наш игрок.

Глобальные функции в Блупринтах

Если вы когда-либо создавали Object Блупринт, то могли заметить, что в них нельзя вызвать глобальные функции (статичные, но для понятности назовем так), которые доступны в остальных классах, например, GetGamemode(). Создается ощущение, что вы просто-напросто не можете делать в Object классах, из-за чего вам приходится либо передавать все ссылки при создании, либо же как-то извращаться, а иногда выбор и вовсе падает на класс Actor, который создается на сцене и поддерживает все на свете.

Однако гейм-дизайнеру, который играется с настройками и добавляет разные мелочи, не скажешь, что нужно открыть Visual Studio, найти соответствующий класс и в функции doSomething() получить игровой режим, изменив в нем очки. А вот в С++, конечно же, таких проблем нет. Сэкономите и его время, и ваше. Поэтому крайне важно, чтобы дизайнер мог зайти в Блупринт и двумя щелчками мыши сделать то, в чем заключается его работа. Впрочем, Блупринты для этого и придуманы.

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

Даже два. Впрочем, и от этого есть лекарство.

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

.h

UCLASS(Blueprintable)
class MYPROPJECT_API UMyObject : public UObject
{ GENERATED_BODY() // Переопределяем GetWorld() для возвращения корректной ссылки. virtual UWorld* GetWorld() const override; // Остальной код
}

.cpp

UWorld* UMyObject::GetWorld() const
{ // Возвращаем ссылку на мир из владельца объекта, если не работаем редакторе.
if (GIsEditor && !GIsPlayInEditorWorld) return nullptr; else if (GetOuter()) return GetOuter()->GetWorld(); else return nullptr;
}

Теперь она определена и редактор будет понимать, что в целом объект способен получить нужный указатель (хоть он не валидный) и использовать глобальные функции в БП.

Это может быть другой UObject с определенным GetWorld(), компонент или Actor объект на сцене. Обратите внимание, что владелец (GetOuter()) тоже должен иметь выход к миру.

Достаточно добавить в макрос UCLASS() при объявлении класса метку о том, что статичным функциям в БП будет добавляться параметр WorldContextObject, в который подается любой объект, служащий проводником в «мир» и глобальным функциям движка. Есть и иной способ. Этот вариант подойдет тем, у кого в проекте может быть несколько миров одновременно (например, игровой мир и мир для спектатора):

.h

// Добавим вывод WorldContext парамерта в функции в БП
UCLASS(Blueprintable, meta=(ShowWorldContextPin))
class MYPROPJECT_API UMyObject : public UObject
{ GENERATED_BODY() // Остальной код
}

Если ввести в поиск в БП GetGamemode, он появится в списке, как и другие подобные функции, и в параметре будет WorldContextObject, в который нужно передавать ссылку на Actor.

Я рекомендую создать функцию на Actor’а, это будет всегда полезно для объекта: К слову, можно просто подавать туда владельца нашего объекта.

.h

UCLASS(Blueprintable, meta=(ShowWorldContextPin))
class MYPROPJECT_API UMyObject : public UObject
{ GENERATED_BODY() // Объявим БП чистую и публичную функцию, которая выводит владельца нашего объекта.
public: UFUNCTION(BlueprintPure) AActor* GetOwner() const {return Cast<AActor>(GetOuter());}; // Остальной код }

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

Если вы во втором варианте так же объявите GetWorld() как и в первом варианте, то сможете подавать в параметр WorldContextObject ссылку на себя (Self или This).

BeginPlay и Tick события

Еще одна проблема, с которыми могут столкнуться разработчики на Блупринтах — в Object классе нет событий BeginPlay и Tick. Безусловно, вы можете их создать сами и вызывать из другого класса. Но согласитесь, что гораздо удобнее, когда это все работает из коробки.

Мы можем создать доступную для перезаписи в БП функцию и вызывать ее в конструкторе класса, но тут есть ряд проблем, так как на момент конструктора ваш объект еще полностью не инициализирован. Давайте для начала разберем, как сделать Begin Play.

В ней как раз можно вызвать наше событие, которое будет использоваться в Блупринтах: Во всех классах существует функция PostInitProperties(), которая вызывается после инициализации большинства параметров и регистрации объекта в различных внутренних системах, например, для сборщика мусора.

.h

UCLASS(Blueprintable)
class MYPROPJECT_API UMyObject : public UObject
{ GENERATED_BODY() // Перезаписываем функцию для вызова после инициализации. virtual void PostInitProperties() override; // Функция, которую определяем мы уже в Блупринтах. UFUNCTION(BlueprintImplementableEvent) void BeginPlay(); // Остальной код
}

.cpp

void UMyObject::PostInitProperties()
{ Super::PostInitProperties(); //Вызываем только в игре, когда есть мир. В редакторе BeginPlay вызван не будет if(GetOuter() && GetOuter()->GetWorld()) BeginPlay();
}

Вместо if(GetOuter() && GetOuter()->GetWorld()) можно поставить просто if(GetWorld()) если вы его уже переопределили.

По умолчанию PostInitProperties() вызывается и в редакторе.
Теперь мы можем зайти в наш БП объект и вызвать событие BeginPlay. Будьте осторожны! Оно будет вызываться при создании объекта.

Тут простой функцией нам не обойтись. Перейдем к Event Tick. Однако, тут есть очень удобная хитрость — дополнительное наследование от FTickableGameObject. Tick объектов в движке вызывает специальный менеджер, к которому нужно как-то подцепиться. Это позволит автоматически сделать все, что нужно, и тогда достаточно будет просто подцепить необходимые функции:

.h

// Необходимый инклуд
#include "Tickable.h" // Множественное наследование c FTickableGameObject
UCLASS(Blueprintable)
class MYPROPJECT_API UMyObject : public UObject, public FTickableGameObject
{ GENERATED_BODY() public: // Необходимые функции virtual void Tick(float DeltaTime) override; virtual bool IsTickable() const override; virtual TStatId GetStatId() const override; protected: // Для определения в БП UFUNCTION(BlueprintImplementableEvent) void EventTick(float DeltaTime); // Остальной код
}

.cpp

void UMyObject::Tick(float DeltaTime)
{ // Вызываем наше событие для определения в БП. EventTick(DeltaTime); // Остальной плюсовый код на тик.
} // Необходимо тикать или нет
bool UMyObject::IsTickable() const
{ return true;
} TStatId UMyObject::GetStatId() const
{ return TStatId();
}

Если вы отнаследуетесь от вашего объекта и создадите БП класс, то будет доступно событие EventTick, вызывающее логику каждый кадр.

Добавление компонентов из UObject’ов

В Блупринтах UObject нельзя спавнить компоненты для Actor’ов. Эта же проблема свойственна и Блупринтам ActorComponent. Не очень понятна логика Epic Games, так как в C++ это можно сделать. Более того, вы можете добавить компонент из Actor’а другому Actor объекту просто указав ссылку. Но сделать этого нельзя.

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

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

Настройка экземпляра через редактор

В UE4 есть еще одна удобная «фича» для работы с объектами — это возможность создать экземпляр во время инициализации и менять его параметры через редактор, тем самым настроив его свойства, не создавая дочерний класс только ради настроек. Особенно полезно гейм-дизайнерам.

Гейм-дизайнер создал пару модификаторов и указывает в менеджере, какие используются. Допустим, у вас есть менеджер модификаторов для персонажа и сами модификаторы представлены классами, в которых описываются накладываемые эффекты.

В обычной ситуации выглядело бы вот так:

.h

class MYPROPJECT_API AMyActor : public AActor
{ GENERATED_BODY() public: UPROPERTY(EditAnywhere) TSubclassOf<class UMyObject> MyObjectClass;
}

Согласитесь, не очень удобно иметь десятки классов в Content Browser, которые отличаются лишь значениями. Однако тут возникает проблема в том, что настроить модификаторы он не может и приходится создавать дополнительный класс для других значений. Можно добавить внутрь USTRUCT() пару полей, а также указать в объекте-контейнере, что наши объекты будут экземплярами, а не просто ссылками на несуществующий объект или классами: Исправить это несложно.

.h

UCLASS(Blueprintable, DefaultToInstanced, EditInlineNew) // Добавляем мета-теги для поддержки редактирования экземпляра из окна параметров
class MYPROPJECT_API UMyObject : public UObject
{ GENERATED_BODY() UPROPERTY(EditAnywhere) // Можно редактировать переменную в окне параметров uint8 MyValue; // Переменная, которую мы отредактируем // Остальной код
}

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

.h

class MYPROPJECT_API AMyActor : public AActor
{ GENERATED_BODY() public: UPROPERTY(EditAnywhere, Instanced) // Добавляем тег Instanced для создания экземпляра class UMyObject* MyObject; // Ссылка на объект }

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

image

Info

Есть в Unreal Engine еще один интересный класс. Это AInfo. Класс, унаследованный от AActor, не имеющий визуального представления в мире. Info используют такие классы, как: игровой режим, GameState, PlayerState и прочие. То есть классы, которые поддерживают разные фишки от AActor, например, репликацию, но при этом не размещены на сцену.

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

Резонно использование в малых количествах и ради удобства. Однако учтите, что хоть и у объекта нет координат, визуальных компонентов и он не рендерится на экране, он все равно является наследником Actor класса, а значит, настолько же тяжелый, как и родитель.

Заключение

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

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

Если вдруг какая-то часть не компилируется, то можете сообщить об этом в комментариях или в личку. Надеюсь, статья будет полезна тем, кто изучает или работает с Unreal Engine 4. Также буду очень благодарен, если кто-то знает еще различные полезности, связанные с UObject.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть