Переход на UNIGINE с Unreal Engine 4: гайд для программистов

Написание игровой логики, триггеры, ввод, рейкастинг и другое.

868a9dc95fca9125539f27915ec3755d.png

Специально для тех, кто ищет альтернативу Unreal Engine или Unity, мы продолжаем цикл статей про безболезненный переход на UNIGINE с зарубежных движков. В третьем выпуске рассмотрим миграцию с Unreal Engine 4 с точки зрения программиста.

Общая информация

Игровая логика в проекте на Unreal Engine 4 реализуется с помощью классов C++ или Blueprint Visual Scripting — встроенной системы визуального нодового программирования. Редактор Unreal Engine 4 позволяет создавать классы при помощи встроенного мастера классов (Class Wizard), выбрав нужный базовый тип.

В UNIGINE вы можете создавать проекты, используя C++ и C# API. При создании проекта просто выберите желаемое API и систему сборки:

421e50397856d1a2288a26d45e589148.png

В данной статье в основном затронем программирование на C++, т.к. полноценное программирование в Unreal Engine 4 возможно именно на этом языке.

Для C++ на выбор представлены готовые шаблоны проектов для следующих систем сборки:

  • Windows:

  • Linux:

Далее просто выберите Open Code IDE, чтобы перейти к разработке логики в выбранной IDE для C++ проектов:

52907dcc2e855dccca5af59001a2ef28.png

В Unreal Engine 4 достаточно унаследовать класс от базовых типов Game Framework, таких как AActor, APawn, ACharacter и т.п., чтобы переопределить их поведение в стандартных методах BeginPlay (), Tick () и EndPlay () и получить пользовательский actor.

Компонентный подход подразумевает, что логика реализуется в пользовательских компонентах, назначаемых на actor«ы — классах, унаследованных от UActorComponent и других компонентов, расширяющих стандартное поведение, определенное в методахInitializeComponent () и TickComponent ().

В UNIGINE стандартный подход подразумевает, что логика приложения состоит из трех основных компонентов с разным циклом жизни:

  • Системная логика (исходный файл AppSystemLogic.cpp) существует в течение жизненного цикла приложения.

  • Логика мира (исходный файл AppWorldLogic.cpp) выполняется только когда мир загружен.

  • Логика редактора (исходный файл AppEditorLogic.cpp) выполняется только во время работы  пользовательского редактора.

У каждой логики есть стандартные методы, вызываемые в основном цикле движка. К примеру, можно использовать следующие методы логики мира:

  • init () — для инициализации ресурсов при загрузке мира;

  • update () — для обновления каждый кадр;

  • shutdown () — для уничтожения использованных ресурсов при закрытии мира;

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

Компонентный подход также доступен в UNIGINE при помощи встроенной компонентной системы. Логика компонента определяется в классе, производном от ComponentBase, на основе которого движок сгенерирует набор параметров компонента — Property, которые можно назначить любой ноде в редакторе. Каждый компонент также имеет набор методов, которые вызываются соответствующими функциями основного цикла движка.

Для примера создания простой игры с использованием компонентной системы, обратитесь к серии статей «Краткое руководство по программированию».

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

UCLASS()
class UMyComponent : public UActorComponent
{
    GENERATED_BODY()

public:
    UPROPERTY(EditAnywhere)
    int32 TotalDamage;


    // Called after the owning Actor was created
    void InitializeComponent();

    // Called when the component or the owning Actor is being destroyed
    void UninitializeComponent();

    // Component version of Tick
    void TickComponent(float DeltaTime, enum ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction);
};

И в UNIGINE. Компонентную систему сперва необходимо инициализировать в системной логике (AppSystemLogic.cpp):

/* .. */
#include 

/* .. */

int AppSystemLogic::init()
{
    Unigine::ComponentSystem::get()->initialize();
    return 1;
}

И тогда можно написать новый компонент:

MyComponent.h:

#pragma once
#include 
#include 

using namespace Unigine;
class MyComponent : public ComponentBase
{
public:
    // объявление компонента MyComponent
    COMPONENT(MyComponent, ComponentBase);

    // объявление методов, вызываемых на определенных этапах цикла жизни компонента
    COMPONENT_INIT(init);
    COMPONENT_UPDATE(update);
    COMPONENT_SHUTDOWN(shutdown);

    // объявление параметра компонента, который будет доступен в редакторе
    PROP_PARAM(Float, speed, 30.0f);

    // определение имени Property, которое будет сгенерировано и ассоциировано с компонентом
    PROP_NAME("my_component");
protected:
    void init();
    void update();
    void shutdown();
};

MyComponent.cpp:

#include "MyComponent.h"

// регистрация компонента MyComponent
REGISTER_COMPONENT(MyComponent);

// вызов будет произведен при инициализации компонента
void MyComponent::init(){}

// будет вызван каждый кадр
void MyComponent::update(){}

// будет вызван при уничтожении компонента или ноды, которой он назначен
void MyComponent::shutdown(){}

Теперь необходимо сгенерировать property для нашего компонента. Для этого:

  1. Соберите приложение с помощью IDE.

  2. Запустите приложение один раз, чтобы получить property компонента, сгенерированное движком.

  3. Перейдите в редактор и назначьте сгенерированное property ноде.

  4. Наконец, работу логики компонента можно проверить, запустив приложение.

Чтобы узнать больше о последовательности выполнения и о том, как создавать компоненты, перейдите по ссылкам ниже:

Немного про API

Все объекты в Unreal Engine 4 наследуются от UObject, доступ к ним возможен при помощи стандартных C++ указателей или умных указателей Unreal Smart Pointer Library.

В UNIGINE API есть система умных указателей, управляющих существованием нод и других объектов в памяти:

// создать ноду типа NodeType
Ptr nodename = ::create();
// удалить ноду из мира
nodename.deleteLater();

К примеру, вот как выглядит создание меша из ассета, редактирование, присвоение новой ноде типа ObjectMeshStatic и удаление:

    MeshPtr mesh = Mesh::create();
    mesh->load("fbx/model.fbx/model.mesh");
    mesh->addBoxSurface("box_surface", Math::vec3(0.5f, 0.5f, 0.5f));
    ObjectMeshStaticPtr my_object = ObjectMeshStatic::create(mesh);
    my_object.deleteLater();
    mesh.clear();

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

MyComponent *my_component = getComponent(node);

Типы данных

Тип данных

Unreal Engine 4

UNIGINE

Числовые типы

int8/uint8

int16/uint16

int32/uint32

int64/uint64,

float,

double

Стандартные типы C++:

signed и unsigned char, short, int, long, long long, float, double

Строки

FString:

FString MyStr = TEXT («Hello, Unreal 4!»).

String:

String str («Hello, UNIGINE 2!»);

Контейнеры

TArray, TMap, TSet

Vector, Map, Set и другие:

Vector nodes;

World: getNodes (nodes);

for (NodePtr n: nodes) { }

Векторы и матрицы

FVector3f — FVector3d,

FMatrix44f — FMatrtix44d и другие

vec3 — dvec3,

mat4 — dmat4 и другие типы в математической библиотеке.

UNIGINE поддерживает как одинарную точность (Float), так и двойную точность координат (Double), доступную в зависимости от редакции SDK. Почитайте про использование универсальных типов данных, подходящих под любой проект.

Основные примеры кода

Вывод в консоль

Unreal Engine 4

UNIGINE

UE_LOG(LogTemp, Warning, TEXT("Your message"));

Log::message("Debug info: %s\n", text);

Log::message("Debug info: %d\n", number);

См. также:

Загрузка сцены

Unreal Engine 4

UNIGINE

UGameplayStatics::OpenLevel(GetWorld(), TEXT("MyLevelName"));

World::loadWorld("YourWorldName");   

Доступ к Actor / Node из компонента

Unreal Engine 4

UNIGINE

MyComponent->GetOwner();

NodePtr owning_node = node;

См. также:

  • Видеоруководство, демонстрирующее, как получить доступ к нодам из компонентов с помощью C++ Component System.

Доступ к компоненту из Actor / Node

Unreal Engine 4:

UMyComponent* MyComp = MyActor->FindComponentByClass();

UNIGINE:

MyComponent *my_component = getComponent(node);

Работа с направлениями

В Unreal Engine 4 компонент USceneComponent (или производный) отвечает за действия с трансформацией actor«а. Чтобы получить вектор направления по одной из осей с учетом ориентации в мировых координатах, можно использовать соответствующие методы USceneComponent (GetForwardVector ()) или AActor (GetActorForwardVector ()).

В UNIGINE трансформация ноды в пространстве представлена ее матрицей трансформации (mat4), а все основные операции с трансформацией или иерархией нод доступны при помощи методов класса Node. Такой же вектор направления в UNIGINE получается с помощью метода Node: getWorldDirection ():

Unreal Engine 4

UNIGINE

FVector forward = MyActor->GetActorForwardVector();

FVector up = MyActor->GetActorUpVector();

FVector right = MyActor->GetActorRightVector();

FVector CurrentLocation = MyActor->GetActorLocation();

CurrentLocation += forward * speed * DeltaTime;

MyActor->SetActorLocation(CurrentLocation);

mat4 t_local = node->getTransform();

mat4 t_world = node->getWorldTransform();

vec3 pos_world = node->getWorldPosition();

vec3 forward = node->getWorldDirection(Math::AXIS_Y);

vec3 right = node->getWorldDirection(Math::AXIS_X);

vec3 up = node->getWorldDirection(Math::AXIS_Z);

node->translate(forward * speed * Game::getIFps());

См. также:

Более плавный игровой процесс с DeltaTime / IFps

В Unreal Engine 4, чтобы гарантировать, что определенные действия выполняются за одно и то же время независимо от частоты кадров (например, изменение положения один раз в секунду и т. д.), используется множитель deltaTime (время в секундах, которое потребовалось для завершения последнего кадра), передаваемый методу Tick (float deltaTime). То же самое в UNIGINE называется Game: getIFps ():

Unreal Engine 4

UNIGINE

void AMyActor::Tick(float deltaTime)

{

    Super::Tick(deltaTime);

    /* .. */

}

node->rotate(0, 0, speed * Game::getIFps());

Рисование отладочных данных

Unreal Engine 4:

DrawDebugLine(GetWorld(), traceStart, traceEnd, FColor::Green, true, 1.0f);

В UNIGINE за вспомогательную отрисовку отвечает синглтон Visualizer:

// включаем вспомогательную визуализацию
Visualizer::setEnabled(true);

/*..*/

Visualizer::renderLine3D(vec3_zero, vec3(5, 0, 0), vec4_one);
Visualizer::renderVector(node->getPosition(), node->getDirection(Math::AXIS_Y) * 10, vec4(1, 0, 0, 1));

Примечание. Visualizer также можно включить с помощью консольной команды show_visualizer 1.

См. также:

Поиск Actor / Node

Unreal Engine 4:

// поиск Actor или UObject по имени
AActor* MyActor = FindObject(nullptr, TEXT("MyNamedActor"));

// Поиск Actor по типу
for (TActorIterator It(GetWorld()); It; ++It)
{
    AMyActor* MyActor = *It;
    // ...
}

UNIGINE:

// поиск Node по имени
NodePtr my_node = World::getNodeByName("my_node");

// поиск всех нод с данным именем
Vector nodes;
World::getNodesByName("test", nodes);

// получение прямого потомка ноды
int index = node->findChild("child_node");
NodePtr direct_child = node->getChild(index);

// Рекурсивный поиск ноды по имени среди всех потомков в иерархии
NodePtr child = node->findNode("child_node", 1);

Приведение от типа к типу

Классы всех типов нод являются производными от Node в UNIGINE, поэтому чтобы получить доступ к функциональности ноды определенного типа (например, ObjectMeshStatic), необходимо провести понижающее приведение типа — Downcasting (приведение от базового типа к производному), которое выполняется с использованием специальных конструкций. Чтобы выполнить Upcasting (приведение от производного типа к базовому), можно как обычно просто использовать сам экземпляр:

Unreal Engine 4

UNIGINE

UPrimitiveComponent* Primitive = MyActor->GetComponentByClass(UPrimitiveComponent::StaticClass());

USphereComponent* SphereCollider = Cast(Primitive);

if (SphereCollider != nullptr)

{

// ...

}

// поиск ноды в мире по имени

NodePtr baseptr = World::getNodeByName("my_meshdynamic");

// приведение к производному типу с автоматической проверкой типа

ObjectMeshDynamicPtr derivedptr = checked_ptr_cast(baseptr);

// статическое приведение

ObjectMeshDynamicPtr derivedptr = static_ptr_cast(World::getNodeByName("my_meshdynamic"));

// приведение к Object — базовому типу для ObjectMeshDynamic

ObjectPtr object = derivedptr;

// приведение к Node — базовому типу для всех объектов мира

NodePtr node = derivedptr;

Уничтожение Actor / Node

Unreal Engine 4

UNIGINE

MyActor->Destroy();

// уничтожение actor’а с 1-секундной задержкой

MyActor->SetLifeSpan(1);

node.deleteLater(); // рекомендуемый способ уничтожить ноду

//вызов будет произведен между кадрами

node.deleteForce(); // форсированное удаление, может быть небезопасным

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

Создание экземпляра Actor / Node Reference

За создание нового экземпляра actor (Spawning) отвечает метод UWorld: SpawnActor ():

AKAsset* SpawnedActor1 = (AKAsset*)
GetWorld()->SpawnActor(AKAsset::StaticClass(), NAME_None, &Location);

В Unreal Engine 4 клонировать существующий actor можно следующим образом:

AMyActor* CreateCloneOfMyActor(AMyActor* ExistingActor, FVector SpawnLocation, FRotator SpawnRotation)
{
UWorld* World = ExistingActor->GetWorld();
FActorSpawnParameters SpawnParams;
SpawnParams.Template = ExistingActor;
World->SpawnActor(ExistingActor->GetClass(), SpawnLocation, SpawnRotation, SpawnParams);
}

В UNIGINE используйте Node: clone () для клонирования ноды, существующей в мире, и World: loadNode для загрузки иерархии нод из ассета .node. В этом случае на сцену будет добавлена ​​вся иерархия нод, которая была сохранена как Node Reference. Вы можете обратиться к ассету либо через параметр компонента, либо вручную, указав виртуальный путь к нему:

// MyComponent.h
PROP_PARAM(File, node_to_spawn);

// MyComponent.cpp
/* .. */
void MyComponent::init()
{
// создание новой ноды Dummy
NodeDummyPtr dummy = NodeDummy::create();

// клонирование существующей ноды
NodePtr cloned = dummy->clone();

// загрузка иерархии нод из ассета
NodePtr spawned = World::loadNode(node_to_spawn.get());
spawned->setWorldPosition(node->getWorldPosition());

// загрузка с указанием пути в файловой системе
NodePtr spawned_manually = World::loadNode("nodes/node_reference.node");
}

Для параметра компонента также необходимо указать ассет .node в редакторе:

21fc27c6b8f80f0eac98c9d30b47ce72.png

Еще один способ загрузить содержимое ассета *.node — создать NodeReference и работать с иерархией нод как с одним объектом. Тип Node Reference имеет ряд внутренних оптимизаций и тонких моментов (кэширование нод, распаковка иерархии и т.д.), поэтому важно учитывать специфику работы с этими объектами.

void MyComponent::update()
{
NodeReferencePtr nodeRef = NodeReference::create("nodes/node_reference_0.node");
}

Запуск скриптов в редакторе

Unreal Engine 4 позволяет расширять функциональность редактора с помощью Blueprint/Python скриптов.

UNIGINE не поддерживает выполнение логики приложения на C++ внутри редактора. Основной способ расширить функциональность редактора — плагины, написанные на C++.

Для быстрого тестирования или автоматизации разработки можно написать логику на UnigineScript. UnigineScript API обладает только базовой функциональностью и ограниченной сферой применения, но доступен для любого проекта на UNIGINE, включая проекты на C++.

Есть два способа добавить скриптовую логику в проект:

  1. Создайте ассет скрипта .usc.

6b125af126a18aac7514b7e3e23a7f15.png

  1. Определите в нем логику. При необходимости добавьте проверку, загружен ли редактор:

//Исходный код (UnigineScript)
#include 
vec3 lookAtPoint = vec3_zero;
Node node;
int init() {
    node = engine.world.getNodeByName("material_ball");
    return 1;
}
int update() {
if(engine.editor.isLoaded())
        node.worldLookAt(lookAtPoint);
    return 1;
}
  1. Выделите текущий мир и укажите для него сценарий мира. Нажмите Apply и перезагрузите мир.

027cbe42aa5d80da93a2f5b92d8f3f83.png

  1. Проверьте окно консоли на наличие ошибок.

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

  • Используя WorldExpression. С той же целью можно использовать ноду WorldExpression, выполняющую логику при добавлении в мир:

  1. Нажмите Create → Logic → Expression и поместите новую ноду WorldExpression в мир.

  2. Напишите логику на UnigineScript в поле Source:

//Исходный код (UnigineScript)
{
vec3 lookAtPoint = vec3_zero;
Node node = engine.world.getNodeByName("my_node");
node.worldLookAt(lookAtPoint);
}
  1. Проверьте окно Console на наличие ошибок.

  2. Логика будет выполнена немедленно.

Триггеры

Unreal Engine 4:

UCLASS()
class AMyActor : public AActor
{
GENERATED_BODY()

// компонент триггера
UPROPERTY()
UPrimitiveComponent* Trigger;

AMyActor()
{
    Trigger = CreateDefaultSubobject(TEXT("TriggerCollider"));

    Trigger.bGenerateOverlapEvents = true;
    Trigger.SetCollisionEnabled(ECollisionEnabled::QueryOnly);
}

virtual void NotifyActorBeginOverlap(AActor* Other) override;

virtual void NotifyActorEndOverlap(AActor* Other) override;
};

В UNIGINE Trigger — это специальный тип нод, вызывающих события в определенных ситуациях:

Важно! PhysicalTrigger не обрабатывает события столкновения, для этого тела и сочленения предоставляют свои собственные события.

WorldTriger — наиболее распространенный тип триггера, который можно использовать в игровой логике:

WorldTriggerPtr trigger;
int enter_callback_id;
                
// коллбэк при попадании внутрь объема триггера
void AppWorldLogic::enter_callback(NodePtr node){
    Log::message("\nA node named %s has entered the trigger\n", node->getName());
}
                
// implement the leave callback
void AppWorldLogic::leave_callback(NodePtr node){
    Log::message("\nA node named %s has left the trigger\n", node->getName());
}
                
int AppWorldLogic::init() {
    // создание WorldTrigger ноды
    trigger = WorldTrigger::create(Math::vec3(3.0f));
                
    // подписка на событие попадания ноды внутрь объема триггера
    // и сохранение id коллбэка для будущего удаления
    enter_callback_id = trigger->addEnterCallback(MakeCallback(this, &AppWorldLogic::enter_callback));
    // подписка на событие покидания нодой объема триггера
    trigger->addLeaveCallback(MakeCallback(this, &AppWorldLogic::leave_callback));

    return 1;
}

Обработка ввода

Unreal Engine 4:

UCLASS()
class AMyPlayerController : public APlayerController
{
    GENERATED_BODY()

    void SetupInputComponent()
    {
        Super::SetupInputComponent();

        InputComponent->BindAction("Fire", IE_Pressed, this, &AMyPlayerController::HandleFireInputEvent);
        InputComponent->BindAxis("Horizontal", this, &AMyPlayerController::HandleHorizontalAxisInputEvent);
        InputComponent->BindAxis("Vertical", this, &AMyPlayerController::HandleVerticalAxisInputEvent);
    }

    void HandleFireInputEvent();
    void HandleHorizontalAxisInputEvent(float Value);
    void HandleVerticalAxisInputEvent(float Value);
};

UNIGINE:

/* .. */

#include 
#include 
#include 

/* .. */

void MyInputController::update() 
{
    // при нажатии правой кнопки мыши
    if (Input::isMouseButtonDown(Input::MOUSE_BUTTON_RIGHT))
    {
        Math::ivec2 mouse = Input::getMouseCoord();
        // сообщить координаты курсора мыши в консоль
        Log::message("Right mouse button was clicked at (%d, %d)\n", mouse.x, mouse.y);
    }
    
    // закрыть приложение при нажатии клавиши 'Q' с учетом того, открыта ли консоль
    if (Input::isKeyDown(Input::KEY_Q) && !Console::isActive())
    {
        App::exit();
    }
}

/* .. */

Также можно использовать синглтон ControlsApp для обработки привязок элементов управления к набору предустановленных состояний ввода. Чтобы настроить привязки, откройте настройки Controls в редакторе:

#include 

/* .. */

void MyInputController::init() 
{
    // переназначение состояний клавишам и кнопкам вручную
    ControlsApp::setStateKey(Controls::STATE_FORWARD, App::KEY_PGUP);
    ControlsApp::setStateKey(Controls::STATE_BACKWARD, App::KEY_PGDOWN);
    ControlsApp::setStateKey(Controls::STATE_MOVE_LEFT, 'l');
    ControlsApp::setStateKey(Controls::STATE_MOVE_RIGHT, 'r');
    ControlsApp::setStateButton(Controls::STATE_JUMP, App::BUTTON_LEFT);
}

void MyInputController::update() 
{
    if (ControlsApp::clearState(Controls::STATE_FORWARD))
    {
        Log::message("FORWARD key pressed\n");
    }
    else if (ControlsApp::clearState(Controls::STATE_BACKWARD))
    {
        Log::message("BACKWARD key pressed\n");
    }
    else if (ControlsApp::clearState(Controls::STATE_MOVE_LEFT))
    {
        Log::message("MOVE_LEFT key pressed\n");
    }
    else if (ControlsApp::clearState(Controls::STATE_MOVE_RIGHT))
    {
        Log::message("MOVE_RIGHT key pressed\n");
    }
    else if (ControlsApp::clearState(Controls::STATE_JUMP))
    {
        Log::message("JUMP button pressed\n");
    }
}

/* .. */

Проверка пересечения луча с геометрией (Raycast) 

Unreal Engine 4:

APawn* AMyPlayerController::FindPawnCameraIsLookingAt()
{
FCollisionQueryParams Params;
Params.AddIgnoredActor(GetPawn());

FHitResult Hit;

FVector Start = PlayerCameraManager->GetCameraLocation();
FVector End = Start + (PlayerCameraManager->GetCameraRotation().Vector() * 1000.0f);
bool bHit = GetWorld()->LineTraceSingle(Hit, Start, End, ECC_Pawn, Params);

if (bHit)
{
    return Cast(Hit.Actor.Get());
}

return nullptr;
}

В UNIGINE то же самое достигается с помощью Intersections:

#include "MyComponent.h"
#include 
#include 
#include 
#include 

using namespace Unigine;
using namespace Math;

REGISTER_COMPONENT(MyComponent);

void MyComponent::init()
{
    Visualizer::setEnabled(true);
}

void MyComponent::update()
{
    // получим координаты начальной и конечной точек луча
    ivec2 mouse = Input::getMouseCoord();
    float length = 100.0f;
    vec3 start = Game::getPlayer()->getWorldPosition();
    vec3 end = start + vec3(Game::getPlayer()->getDirectionFromScreen(mouse.x, mouse.y)) * length;

    // игнорируем поверхности мешей с включенными битами маски Intersection
    int mask = ~(1 << 2 | 1 << 4);

    WorldIntersectionNormalPtr intersection = WorldIntersectionNormal::create();

    ObjectPtr obj = World::getIntersection(start, end, mask, intersection);

    if (obj)
    {
        vec3 point = intersection->getPoint();
        vec3 normal = intersection->getNormal();
        Visualizer::renderVector(point, point + normal, vec4_one);
        Log::message("Hit %s at (%f,%f,%f)\n", obj->getName(), point.x, point.y, point.z);
    }
}

Напоминаем, что получить доступ к бесплатной версии UNIGINE 2 Community можно, заполнив форму на нашем сайте.

Все комплектации UNIGINE:

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

  • Engineering — расширенная, специализированная версия. Включает множество заготовок для инженерных задач.

  • Sim — максимальная версия платформы под масштабные проекты (размеров планеты и даже больше) с готовыми механизмами симуляции.

Подробнее о комплектациях и ценах

© Habrahabr.ru