Kotlin и Jetpack Compose: портируем DOOM на смарт-часы

от автора

DOOM на смарт‑часах Samsung, скриншот автора

DOOM на смарт‑часах Samsung, скриншот автора

DOOM, пожалуй, самый известный шутер от первого лица в истории компьютерных игр. Эта игра не только завоевала коммерческий успех, но и заслужила репутацию одной из лучших и наиболее влиятельных видеоигр всех времен. В 1999 году исходный код Doom был выпущен под лицензией GNU General Public License, и с тех пор он был портирован на множество платформ.

Очевидно, что я не первый, кто решил запустить DOOM на Android. Эта статья была вдохновлена проектом Doom‑Android на GitHub, который, в свою очередь, был основан на другом проекте под названием Doom‑Generic. Последний, в свою очередь, базируется на порте fbDOOM, который основан на… (ну вы поняли). Проект Doom‑Android работает хорошо, однако он использует «классический» Android API. Он не поддерживает Jetpack Compose, и я также раньше не видел Doom на современных устройствах Android Wear. Итак, без лишних слов, давайте приступать к портированию!

Я взялся за этот проект для Android Wear просто потому, что это интересно, и не так много людей видели, как на часах работает полноценная 3D‑игра. Однако я также хочу, чтобы проект был доступен и для «стандартного» Android. Таким образом, читатели, у которых нет смарт‑часов, смогут наслаждаться тем же кодом на своих смартфонах.

Структура приложения

Чтобы запустить DOOM в Jetpack Compose, нам предстоит проделать несколько важных шагов:

  • Связать исходный код DOOM (который был написан на C) с Kotlin, используя JNI (Java Native Interface). Это позволит нам запустить игру и получить данные из ее видеобуфера.

  • Внести некоторые изменения в исходный код DOOM, чтобы сделать его совместимым со стандартами Android.

  • Использовать GLSurfaceView и GLSurfaceView.Renderer — компоненты Android, которые будут отображать видеоданные из DOOM.

  • Наконец, запустить код «реактивно» с помощью Jetpack Compose.

Итак, давайте приступим!

1. JNI (Java Native Interface)

Исходный код DOOM очень старый. Настолько старый, что некоторые читатели, вероятно, еще даже не родились, когда он был написан. В начале файла d_main.h мы можем увидеть строки:

Щ// // Copyright(C) 1993-1996 Id Software, Inc. //

Очевидно, что этот код старше как Kotlin, так и Android, и даже самой Java (первая версия JDK 1.0 была выпущена в 1996 году). Однако, к счастью для нас, есть простой способ запустить код C++ на Android с помощью Java Native Interface (JNI). Эта технология, хотя и не является новой (я видел на форумах вопросы о JNI еще в 2005 году), по‑прежнему прекрасно работает на устройствах Android.

Чтобы запустить DOOM на Android, нам понадобятся как минимум три метода: init, который загрузит игру из WAD‑файла, main, который запустит саму игру, и getFrame, который предоставит нам страницу видеобуфера.

Сначала давайте создадим Kotlin‑класс под названием Doom:

package com.dmitrii.doomsmartwatch  class Doom {     external fun init(wadData: ByteArray, wadFilename: String)     external fun main()     private external fun getFrame(screenBuffer: ByteArray) }

Теперь мы можем создать соответствующие методы C++.

Чтобы связать код Kotlin и C/C ++, JNI использует специальное соглашение об именах. В нашем случае пакет называется com.dmitrii.doomsmartwatch, а класс — Doom. Чтобы создать метод init на C, нужно объединить эти имена:

#include <jni.h>   JNIEXPORT void JNICALL Java_com_dmitrii_doomsmartwatch_Doom_init(         JNIEnv* pEnv,         jobject pThis,         jbyteArray wadData,         jstring wadFilename         ) {     ... }  JNIEXPORT void JNICALL Java_com_dmitrii_doomsmartwatch_Doom_main(         JNIEnv* pEnv,         jobject pThis) {     ... }  JNIEXPORT void JNICALL Java_com_dmitrii_doomsmartwatch_Doom_getFrame(         JNIEnv* pEnv,         jobject pThis,         jbyteArray screenBuffer) {     ... }

После этого все вызовы Kotlin будут автоматически связаны с соответствующими C‑методами.

Чтобы использовать C или C++ в проекте Android Studio, нам также нужно добавить CMakeLists.txt и внести изменения в build.gradle.kts:

CMakeLists.txt:  cmake_minimum_required(VERSION 3.22.1)  project("doomsmartwatch")  add_library(${CMAKE_PROJECT_NAME} SHARED    # Список исходников C/C++    doomgeneric.c    d_main.c    ... )  target_link_libraries(${CMAKE_PROJECT_NAME}     # Список библиотек, связанных с целевой библиотекой     android     log)   build.gradle.kts:  android {     ...     externalNativeBuild {         cmake {             path = file("src/main/cpp/CMakeLists.txt")             version = "3.22.1"         }     } }

Android Studio автоматически создает эти файлы, когда мы впервые добавляем класс C++ в проект. Связывать библиотеку log здесь необязательно, но это позволяет использовать отладку с помощью Logcat:

#include <stdio.h>  #ifdef __ANDROID__  #include <android/log.h> #define printf(...) __android_log_print(ANDROID_LOG_VERBOSE, "Doom", __VA_ARGS__) #define fprintf(a, ...) __android_log_print(ANDROID_LOG_VERBOSE, "Doom", __VA_ARGS__) #define vfprintf(a, ...) __android_log_vprint(ANDROID_LOG_VERBOSE, "Doom", __VA_ARGS__)  #endif

2. Исходники DOOM

На этом этапе мы завершили «скучную» часть с конфигурациями CMake и переходим к самой интересной — изменениям в исходном коде DOOM.

2.1 WAD (Where’s All Data)

Все игровые данные, включая уровни, звуки и т. д., хранятся в так называемом WAD‑файле, который обычно имеет имя, подобное «doom2.wad». Этот файл представляет собой контейнер, в котором собраны все данные, что объясняет его расширение — «Where’s All Data». Если мы выберем другой файл, то запустим другую игру.

В проекте Android мы можем разместить этот файл в папке src/main/assets. Однако исходный код, написанный на C, использует метод fopen для чтения файла, и я не смог найти способ получить полный путь к ресурсам в Kotlin. Вместо этого мы можем отправить данные WAD‑файла в виде массива байтов:

import android.content.Context  external fun init(wadData: ByteArray, wadFilename: String)   filename = "doom2.wad" val wadData = context.assets.open(filename).readBytes() init(wadData, filename)

Соответствующий код на C выглядит следующим образом:

// C-код:  extern char  wadFileName[255]; extern unsigned int wadDataLength; extern unsigned char *wadFileData;   JNIEXPORT void JNICALL Java_com_dmitrii_doomsmartwatch_Doom_init(         JNIEnv* pEnv,         jobject pThis,         jbyteArray wadData,         jstring wadFilename         ) {     // Имя WAD-файла     const char *nativeString = (*pEnv)->GetStringUTFChars(pEnv, wadFilename, 0);     strcpy(wadFileName, nativeString);     (*pEnv)->ReleaseStringUTFChars(pEnv, wadFilename, nativeString);     // Данные WAD-файла     wadDataLength = (*pEnv)->GetArrayLength(pEnv, wadData);     wadFileData = (unsigned char*)malloc(wadDataLength);     jbyte* content_array = (*pEnv)->GetByteArrayElements(pEnv, wadData, 0);     memcpy(wadFileData, content_array, wadDataLength);     (*pEnv)->ReleaseByteArrayElements(pEnv, wadData, content_array, JNI_OK); }

Я не уверен в том, насколько обязательно wadFileName, но оно использовалось несколько раз в исходном коде, поэтому я решил оставить его как есть.

Исходный код DOOM хорошо структурирован, и все модули разделены на отдельные файлы. Например, все операции, связанные с файлами, сосредоточены в файле w_file.c. Здесь нам необходимо внести изменения в методы W_OpenFile и W_Read:

char  wadFileName[255] = {0}; unsigned char *wadFileData = NULL; unsigned int   wadDataLength = 0;   wad_file_t *W_OpenFile(const char *path) {     stdc_wad_file_t *result;      // Старый код     // fstream = fopen(path, "rb");     // if (fstream == NULL)     //    return NULL;      result = Z_Malloc(sizeof(stdc_wad_file_t), PU_STATIC, 0);     result->wad.mapped = NULL;     result->wad.length = wadDataLength;  // M_FileLength(fstream);     result->fstream = NULL;      return &result->wad; }  size_t W_Read(wad_file_t *wad, long offset, void *buffer, size_t buffer_len) {     // Старый код     // fseek(stdc_wad->fstream, offset, SEEK_SET);     // Read into the buffer.     // size_t result = fread(buffer, 1, buffer_len, stdc_wad->fstream);      size_t result = 0;     if (wadFileData != NULL) {         memcpy(buffer, &wadFileData[offset], buffer_len);         result = buffer_len;     }     return result; }

Очевидно, что у нас уже есть все необходимые данные в буфере, поэтому нам больше не нужно использовать методы fseek и fopen. Размер WAD‑файла составляет примерно 16 МБ. Оригинальный DOOM использовал для чтения данных файл, проецируемый в память, но смарт‑часы в 2025 году в среднем имеют в 32 раза больше оперативной памяти (2 ГБ в сравнении с 64 МБ), чем персональные компьютеры 1996 года.

2.2 DoomMain

Второй метод, который мы должны модифицировать, находится в файле d_main.c и называется D_DOOMMAIN. Его исходный код выглядит следующим образом:

void D_DoomMain(void) {     printf("Z_Init: Init zone memory allocation daemon. \n");     Z_Init();     ...      M_LoadDefaults();     iwadfile = D_FindIWAD(IWAD_MASK_DOOM, &gamemission);      W_CheckCorrectIWAD(doom);      printf("I_Init: Setting up machine state.\n");     I_InitSound(True);      printf("R_Init: Init DOOM refresh daemon - ");     R_Init();      printf("\nP_Init: Init Playloop state.\n");     P_Init();      D_DoomLoop(); // бесконечный игровой цикл }  void D_DoomLoop(void) {     I_SetWindowTitle(gamedescription);     I_SetGrabMouseCallback(D_GrabMouseCallback);     I_InitGraphics();      V_RestoreBuffer();     R_ExecuteSetViewSize();      D_StartGameLoop();      while (1)     {         // фрейм-синхронизированные операции ввода-вывода         I_StartFrame();          TryRunTics(); // выполнит хотя бы один тик          S_UpdateSounds(players[consoleplayer].mo);  // перемещает позиционные звуки          // обновляет отображение следующего кадра текущим состоянием         if (screenvisible)             D_Display();     } }

Как и в «классических» приложениях, DOOM имеет бесконечный основной цикл, который обрабатывает все события и обновляет графику. Однако в Jetpack Compose этот подход не работает, и мы не можем блокировать основной цикл приложения таким образом. Тем не менее, исправить это не так сложно. Давайте реорганизуем метод D_DoomLoop, разделив его на две функции: D_DoomInitLoop и d_dooomloopstep. Вот как это будет выглядеть:

void D_DoomInitLoop(void) {     I_SetWindowTitle(gamedescription);     I_SetGrabMouseCallback(D_GrabMouseCallback);     I_InitGraphics();      V_RestoreBuffer();     R_ExecuteSetViewSize();      D_StartGameLoop(); }  void D_DoomLoopStep(void) {     // фрейм-синхронизированные операции ввода-вывода     I_StartFrame();      TryRunTics(); // выполнит хотя бы один тик      S_UpdateSounds(players[consoleplayer].mo); // перемещает позиционные звуки      // обновляет отображение следующего кадра текущим состоянием     if (screenvisible)         D_Display(); }

Теперь мы можем легко вызывать метод D_DoomLoopStep для обновления состояния игры каждый раз, когда Jetpack Compose обновляет представление.

2.3 Графика

Как видно из кода, игровой цикл всегда вызывает метод D_Display. Этот метод отвечает за обновление пользовательского интерфейса игры и вызывает метод I_FinishUpdate, расположенный в файле i_video.c. Однако сейчас нас интересует переменная DG_ScreenBuffer, которая используется в том же файле:

uint32_t DG_ScreenBuffer[DOOMGENERIC_RESX * DOOMGENERIC_RESY];   void I_FinishUpdate(void) {    /* ЭКРАН ОТРИСОВКИ */    line_in  = (unsigned char *) I_VideoBuffer;    line_out = (unsigned char *) DG_ScreenBuffer;     int y = SCREENHEIGHT;    while (y--)     {        for (int i = 0; i < fb_scaling; i++)        {            line_out += x_offset;            cmap_to_fb((void*)line_out, (void*)line_in, SCREENWIDTH);            line_out += (SCREENWIDTH * fb_scaling * (s_Fb.bits_per_pixel/8)) + x_offset_end;        }        line_in += SCREENWIDTH;    }    DG_DrawFrame(); }

Ключевым моментом для нас здесь является то, что DOOM визуализирует всю свою графику в массиве DG_ScreenBuffer. Это идеально подходит для нашей задачи — после каждого игрового шага мы можем отправлять эти данные обратно в Kotlin.

// Сторона Kotlin: private external fun getFrame(screenBuffer: ByteArray)   // Сторона C: JNIEXPORT void JNICALL Java_com_dmitrii_doomsmartwatch_Doom_getFrame(         JNIEnv* pEnv,         jobject pThis,         jbyteArray screenBuffer) {     D_DoomLoopStep();      uint32_t *buffer = DG_ScreenBuffer;     size_t  bufferSize = DOOMGENERIC_RESX * DOOMGENERIC_RESY * 4;     jboolean isCopy;     jbyte *arr = (*pEnv)->GetByteArrayElements(pEnv, screenBuffer, &isCopy);     memcpy(arr, buffer, bufferSize);     (*pEnv)->ReleaseByteArrayElements(pEnv, screenBuffer, arr, JNI_OK); }

Очевидно, что оригинальный метод DG_DrawFrame больше не нужен, поэтому мы можем оставить его пустым:

void DG_DrawFrame(void) { }

3.1 Android-приложение: Jetpack Compose

Наконец, давайте создадим Android‑приложение, которое будет использовать наш код DOOM. Как уже упоминалось ранее, я создал приложение для Android Wear, но обычное приложение для Android тоже должно нормально работать.

Для отрисовки игрового экрана я буду использовать OpenGL. Прежде всего, нам нужно «обернуть» его в AndroidView:

@Composable fun WearApp() {     Box(         modifier = Modifier             .fillMaxSize()             .background(MaterialTheme.colors.background),         contentAlignment = Alignment.Center     ) {         DoomGLView()     } }  @Composable fun DoomGLView() {     val doom = remember {  Doom() }     val viewActive = remember { mutableStateOf(false) }     LifecycleResumeEffect(Unit) {         viewActive.value = true         onPauseOrDispose {             viewActive.value = false         }     }     if (viewActive.value) {         AndroidView(             modifier = Modifier                 .fillMaxSize()                 .clipToBounds(),             factory = { context ->                 DoomGLSurfaceView(context).apply {                 }             },             update = { view ->             },             onRelease = { view ->                 view.glClear()             }         )     } }  class DoomGLSurfaceView(context: Context) : GLSurfaceView(context) {     private val renderer: GLGameRenderer      init {         setEGLContextClientVersion(2)         renderer = GLGameRenderer()         setRenderer(renderer)         renderMode = RENDERMODE_CONTINUOUSLY     }      fun glClear() = renderer.glClear() }

В этом фрагменте кода я использую небольшой хак с viewActive, который позволяет удалять представление, когда приложение больше не активно. Это единственный способ, который я нашёл, чтобы правильно высвободить все данные OpenGL. Без этого приложение не восстанавливалось корректно после перехода в фоновый режим (если кто‑то знает способ получше, пожалуйста, поделитесь им в комментариях).

Минимально рабочий код рендеринга OpenGL выглядит следующим образом:

import android.opengl.GLES20   class GLGameRenderer : GLSurfaceView.Renderer {     override fun onSurfaceCreated(glUnused: GL10, config: EGLConfig) {     }      override fun onSurfaceChanged(glUnused: GL10, width: Int, height: Int) {         GLES20.glViewport(0, 0, width, height)         glInit()     }      override fun onDrawFrame(glUnused: GL10) {         GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT or GLES20.GL_DEPTH_BUFFER_BIT)         GLES20.glClearColor(0f, 0.5f, 0f, 1f)     }      fun glInit() {     }      fun glClear() {     } }

Здесь мы не инициализируем никаких текстур или шейдеров, а просто очищаем экран цветом RGB = (0, 0.5, 0). Методы onSurfaceCreated и onSurfaceChanged вызываются, когда приложение создает представление. Ранее я установил для renderMode значение RENDERMODE_CONTINUOUSLY, чтобы операционная система постоянно вызывала метод onDrawFrame (в моём эмуляторе интервал между обновлениями составляет около 17 мс). При каждом вызове onDrawFrame мы можем обновлять состояние игры и перерисовывать изображение.

Хоть это приложение и не претендует на звание «Дизайн года», мы уже можем убедиться, что OpenGL функционирует должным образом. Если все было сделано правильно, мы должны увидеть зеленую поверхность, как показано на рисунке ниже:

Простой рисунок в OpenGL

Простой рисунок в OpenGL

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

3.2. Android-приложение: OpenGL

Программирование на OpenGL — это обширная тема, и в этой статье я представлю лишь основные концепции, необходимые для запуска игры. Те, кто желает узнать больше, могут почитать официальную документацию на developer.android.com. Также в конце статьи вы можете найти ссылку на исходный код.

На предыдущем шаге мы создали класс GLGameRenderer. При каждом вызове onDrawFrame мы будем обновлять состояние игры и получать обновленный графический массив.

Код на Kotlin выглядит следующим образом:

// Привязка JNI к C++, обсуждавшаяся ранее private external fun getFrame(screenBuffer: ByteArray)   val frameSize = DOOMGENERIC_RESX * DOOMGENERIC_RESY * 4; // 4 байта на пиксель val frameBuffer = ByteArray(size = frameSize) getFrame(frameBuffer)

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

private val textures = intArrayOf(0) private var vertexShader = 0 private var fragmentShader = 0 private var program = 0   private fun glInit() {     vertexShader = loadShader(         GLES20.GL_VERTEX_SHADER,         VERTEX_SHADER_CODE     )     fragmentShader = loadShader(         GLES20.GL_FRAGMENT_SHADER,         FRAGMENT_SHADER_CODE     )     program = GLES20.glCreateProgram().also { program ->         GLES20.glAttachShader(program, vertexShader)         GLES20.glAttachShader(program, fragmentShader)         GLES20.glLinkProgram(program)     }     uniformMvpMatrix = GLES20.glGetUniformLocation(program, "uMvpMatrix")     attributeVertexPosition = GLES20.glGetAttribLocation(program, "aPosition")     attributeTexturePosition = GLES20.glGetAttribLocation(program, "aCoordinate")     uniformTexture = GLES20.glGetUniformLocation(program, "uTexture")     GLES20.glGenTextures(1, textures, 0) }

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

fun glClear() {     if (program != 0) {         GLES20.glDeleteProgram(program)         program = 0     }     if (vertexShader != 0) {         GLES20.glDeleteShader(vertexShader)         vertexShader = 0     }     if (fragmentShader != 0) {         GLES20.glDeleteShader(fragmentShader)         fragmentShader = 0     }     if (textures[0] != 0) {         GLES20.glDeleteTextures(1, textures, 0)         textures[0] = 0     } }

При каждом вызове метода onDrawFrame мы можем обновлять текстуру данными, полученными из DOOM:

const val GAME_WIDTH = 640 const val GAME_HEIGHT = 400  private fun updateTextureFromBuffer(byteArray: ByteArray) {     val buffer = ByteBuffer.wrap(byteArray)     GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textures[0])     GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE.toFloat())     GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE.toFloat())     GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_NEAREST.toFloat())     GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR.toFloat())     GLES20.glTexImage2D(GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA, GAME_WIDTH, GAME_HEIGHT, 0,                         GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, buffer)     GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0) }

Если все сделано правильно, мы можем запустить приложение и увидеть игру:

Первый запуск, изображение автора

Первый запуск, изображение автора

Что ж, это работает. Почти. Очевидно (по крайней мере, для тех, кто хотя бы раз играл в Doom:), что цветовое отображение некорректно.

Исправить это не так сложно. В коде мы можем увидеть константу GLES20.GL_RGBA. Однако в методе I_InitGraphics в коде DOOM цветовые каналы настроены иначе:

void I_InitGraphics(void) {     memset(&s_Fb, 0, sizeof(struct FB_ScreenInfo));     s_Fb.xres = DOOMGENERIC_RESX;     s_Fb.yres = DOOMGENERIC_RESY;     s_Fb.bits_per_pixel = 32;      s_Fb.blue.length = 8;     s_Fb.green.length = 8;     s_Fb.red.length = 8;     s_Fb.transp.length = 8;      s_Fb.blue.offset = 0;     s_Fb.green.offset = 8;     s_Fb.red.offset = 16;     s_Fb.transp.offset = 24;      ... }

Чтобы исправить эту проблему, нам нужно лишь подправить цветовые смещения, чтобы сделать их совместимыми с RGBA:

    s_Fb.red.offset = 0;     s_Fb.green.offset = 8;     s_Fb.blue.offset = 16;     s_Fb.transp.offset = 24;

Если все сделано правильно, мы должны увидеть на экране часов полностью работающий DOOM:

Видео автора

Видео автора

При записи видео некоторая резкость изображения теряется. На настоящих смарт‑часах игра выглядит очень четко, поскольку плотность пикселей на устройствах Android значительно выше, чем на обычных 14-дюймовых дисплеях с разрешением 800×600, которые использовались в 1990-х годах.

Заключение

В этой статье я рассказал, как портировать игру DOOM, созданную в 1993–1996 годах, на современный фреймворк Jetpack Compose для Android. Я протестировал игру на смарт‑часах просто забавы ради, но тот же подход должен работать и на «полноразмерном» устройстве Android.

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

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

  • Звук. Звуковой модуль еще не реализован.

  • Сеть / мультиплеер. Может быть забавно играть в Doom на двух Android‑устройствах, однако я понятия не имею, пробовал ли кто‑нибудь это реализовать.

Если вы хотите увидеть продолжение этой статьи, пожалуйста, поставьте лайк или оставьте комментарий ниже. Я буду ориентироваться на количество лайков и просмотров, чтобы понять, стоит ли писать следующую часть.


Если вы хотите глубже разобраться в инструментах и технологиях, используемых в проекте, вот несколько открытых уроков от Otus, которые вас точно заинтересуют:

  • 3 апреля: Оптимизация CI/CD для мобильных тестов на Kotlin: как избавиться от нестабильных тестов и ускорить развертывание?
    Записаться

  • 16 апреля: Контрактное тестирование в Kotlin QA: как гарантировать, что фронтенд и бэкенд понимают друг друга?
    Записаться

  • 17 апреля: Применение возможностей Kotlin в UI тестировании
    Записаться

Больше открытых уроков по мобильной разработке и не только ищете в календаре мероприятий.


ссылка на оригинал статьи https://habr.com/ru/articles/894514/


Комментарии

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

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