Как стать автором
Обновить

«Морской бой» на Java для новичков. Level 1

Время на прочтение14 мин
Количество просмотров20K

Всем привет!

Статья посвящена тем, кто только врывается в увлекательный мир программирования на Java и ищет применения своим знаниям. Классно, что вы теперь знаете, как создавать переменные, методы и массивы, но, конечно, хочется писать "полезные" для человечества программы, а не выполнять многочисленные мелкие упражнения и задачи, хотя без этого тоже никуда. В общем, будем дополнять теорию практикой. Поехали!

Для начала давайте обсудим, какие требования "бизнес" предъявил к нашему приложению. После долгих переговоров заказчик утвердил следующий сценарий игры:

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

  2. Одновременно в игре могут участвовать только два человека.

  3. В самом начале игроки "представляются" - программа "спрашивает" (предлагает пользователям ввести), какие у них имена

  4. У каждого игрока есть своё поле - квадрат 10х10 клеток

  5. Затем игроки по очереди расставляют свои корабли. Как и в "бумажной" версии - каждый может поставить 4 однопалубных корабля, 3 двухпалубных, 2 трехпалубных и 1 четырёхпалубный.

  6. Корабли можно располагать только по горизонтали или по вертикали.

  7. Игроки не видят расположение кораблей друг друга.

  8. Начинается игра. Первый игрок делает выстрел, сообщая нашему приложению координаты предполагаемой цели - номер клетки по горизонтали и номер клетки по вертикали.

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

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

    Второй вариант, если игрок не попал ни в какой корабль, то ход переходит второму игроку.

  10. Таким образом, как описано в пункте 8, передавая ход друг другу, игроки пытаются как можно раньше уничтожить корабли друг друга. Тот, кто первым, одолеет флотилию врага - победитель. Программа печатает поздравление и прекращает свою работу.

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

Как и в любом Java приложении нам потребуется класс (не умаляя общности назовём его Main), в котором будет объявлен, я думаю уже всем известный, метод main.

public class Main {
	public static void main(String[] args) {
  	//your code will be here
	}
}

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

Опираясь на пункты 1-3 утвержденного сценария, реализуем функционал приложения, который будет предлагать игрокам ввести свои имена. Здесь нам придётся использовать класс java.util.Scanner, который умеет считывать введенные значения в консоли.

public class Main {
    static Scanner scanner = new Scanner(System.in);

    public static void main(String[] args) {
        System.out.println("Player 1, please, input your name");
        String player1Name = scanner.nextLine();
        System.out.println("Hello, " + player1Name + "!");

        System.out.println("Player 2, please, input your name");
        String player2Name = scanner.nextLine();
        System.out.println("Hello, " + player2Name + "!");
    }
}

Подробнее о коде:

В строке 2 создаем static свойство класса Main scanner.

Нестатический метод nextLine() класса Scanner (строки 6 и 11) обращается к консоли и возвращает строку, которую он еще не считывал.

После получения имени пользователей, программа выводит приветствие в консоль - "Hello, {username} !"

В консоли будем видеть следующее, если запустим код сейчас.

Player 1, please, input your name
Egor
Hello, Egor!
Player 2, please, input your name
Max
Hello, Max!

Поговорим о том, как мы будем отображать поле боя и заполнять его кораблями. Пожалуй, что наиболее логичным будет использование двумерного массива char[][] buttlefield. В нем мы будем отображать расположение кораблей. Договоримся, что удачное попадание в корабль противника будем отображать символом #. Неудачный выстрел будем помечать символом *. Таким образом изначально, массив будет проинициализирован дефолтовым для примитива char значением ('\u0000'), а в процессе игры будет заполняться символами # и *.

public class Main {
		static final int FILED_LENGTH = 10;
    static Scanner scanner = new Scanner(System.in);

public static void main(String[] args) {
    System.out.println("Player 1, please, input your name");
    String player1Name = scanner.nextLine();
    System.out.println("Hello, " + player1Name + "!");

    System.out.println("Player 2, please, input your name");
    String player2Name = scanner.nextLine();
    System.out.println("Hello, " + player2Name + "!");
    
    char[][] playerField1 = new char[FILED_LENGTH][FILED_LENGTH];
    char[][] playerField2 = new char[FILED_LENGTH][FILED_LENGTH];

    char[][] playerBattleField1 = new char[FILED_LENGTH][FILED_LENGTH];
    char[][] playerBattleField2 = new char[FILED_LENGTH][FILED_LENGTH];
	}
}

Подробнее о коде:

В строке 2 мы создаем константу FIELD_LENGTH, которая будет содержать размер поля - согласно требованию 4 - проинициализируем FIELD_LENGTH значением 10.

В строках 14-18 создаем двумерные массивы char. playerFiled1 и playerField2 - массивы, в которые будем записывать расположение кораблей каждого игрока. Размеры массивов равны размерам поля - логично, ведь эти двумерные массивы фактически отображают поля.

Перейдём к написанию логике по заполнению полей игроков своими кораблями. Предлагаю создать метод fillPlayerField(playerField), который будет спрашивать у игрока позиции корабля и записывать в массив новый корабль.

public class Main {
    static final int FILED_LENGTH = 10;
    static Scanner scanner = new Scanner(System.in);

    public static void main(String[] args) {
        System.out.println("Player 1, please, input your name");
        String player1Name = scanner.nextLine();
        System.out.println("Hello, " + player1Name + "!");

        System.out.println("Player 2, please, input your name");
        String player2Name = scanner.nextLine();
        System.out.println("Hello, " + player2Name + "!");

        char[][] playerField1 = new char[FILED_LENGTH][FILED_LENGTH];
        char[][] playerField2 = new char[FILED_LENGTH][FILED_LENGTH];

        fillPlayerField(playerField1);
        fillPlayerField(playerField2);
    }
}

private static void fillPlayerField(char[][] playerField) {
	// your code will be here
}

Метод fillPlayerField должен быть статическим (static), так как вызываться он будет из метода main, который по определению должен быть статическим. fillPlayerField не будет возвращать никакого значения (void). В этом методе будет реализована логика по получению координат корабля от пользователя и запись в массив playerField нового корабля.

Поговорим о том, как пользователь будет задавать координаты кораблей. Будем размещать корабли по убыванию палуб, то есть первым разместим четырёхпалубный корабль, затем все трёхпалубные корабли, все двухпалубные корабли и все однопалубные. Программа будет принимать от пользователя координаты начала корабля и его положение (см. бизнес-требование № 6) - по горизонтали или по вертикали.

Приведу вывод программы в консоль, который мы ожидаем увидеть после создания метода fillPlayerField:

Расставляем 4-палубный корабль. Осталось расставить: 1
Input x coord: 
1
Input y coord: 
1
1. Horizontal; 2. Vertical ?
1

Наконец-то приступаем. На данный момент имеем:

private static void fillPlayerField(char[][] playerField) {
    // your code will be here
}

Нам нужно расставить корабли с палубами от 4 до 1. Здесь дело идёт к циклу. Предлагаю без лишнего пафоса использовать for. Заметим, кораблей с одинаковым числом палуб может быть несколько, поэтому нам нужно ещё как-то контролировать, чтобы пользователь мог разместить лишь определенное число кораблей с заданным количеством палуб (см. бизнес требование №5) - эта задача также эффективно решается с помощью цикла - без пафоса также используем for.

private static void fillPlayerField(char[][] playerField) {
		// i - счётчик количества палуб у корабля
    // начинаем расстановку с корабля, которого 4 палубы, а заканчиваем кораблями с одной палубой
    for (int i = 4; i >= 1; i--) {
      	// см. подробнее о коде под этой вставкой
    		for (int k = i; k <= 5 - i; k++) {
          	System.out.println("Расставляем " + i + "-палубный корабль. Осталось расставить: " + (q + 1));
        		// some your code here
        }
    }
}

Подробнее о коде:

На 5 строчке мы создаём цикл for (int k = 0; k <= 5 - i; k++). Объясню, откуда такая магия. Нам нужно как-то понимать, сколько кораблей каждого типа (с определенным количеством палуб) пользователь может поставить.

Мы можем создать еще один двумерный массив, в котором мы захардкодим что-то в духе:

int[][] shipTypeAmount = {{1, 4}, {2, 3}, {3, 2}, {4, 1}};

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

Я же предлагаю отметить особенность, что сумма количества кораблей и количества палуб - величина постоянная. Действительно, 1 + 2 = 5; 2 + 3 = 5;  3 + 2 = 5;  4 + 1 = 5. Поэтому, зная количество палуб у корабля (зная тип корабля), мы можем посчитать, сколько кораблей такого типа может быть установлено одним играком. Например, 5 - 1 = 4- таким образом, каждый игрок может поставить 4 однопалубных корабля. В цикле for на строке 6 реализована проверка условия цикла "лайтовым" способом - на основе этого интересного свойства.

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

private static void fillPlayerField(char[][] playerField) {
        for (int i = 4; i >= 1; i--) {
            // растановка самих кораблей
            for (int k = i; k <= 5 - i; k++) {
                System.out.println("Расставляем " + i + "-палубный корабль. Осталось расставить: " + (q + 1));

                System.out.println("Input x coord: ");
                x = scanner.nextInt();

                System.out.println("Input y coord: ");
                y = scanner.nextInt();

                System.out.println("1 - horizontal; 2 - vertical ?");
                position = scanner.nextInt();

                // если корабль располагаем горизонтально
              	if (position == 1) {
                    // заполняем '1' столько клеток по горизонтали, сколько палуб у корабля
                    for (int q = 0; q < i; q++) {
                        playerField[y][x + q] = '1';
                    }
                }
              
              	// если корабль располагаем вертикально
                if (position == 2) {
                  	// заполняем столько клеток по вертикали, сколько палуб у корабля
                    for (int m = 0; m < i; m++) {
                        playerField[y + m][x] = '1';
                    }
                }
              	// печатаем в консоли поле игрока, на котором будет видно, где игрок уже поставил корабли
              	// о реализации метода - см. ниже
                printField(playerField);
            }
        }
    }

Подробнее о коде:

Корабль помечаем символом '1' столько раз, сколько палуб он имеет - если корабль четырёхпалубный, то он займёт 4 клетки - помечаем 4 клетки значением '1'.

Нам неоднократно потребуется печатать поля игроков, либо поля их выстрелов по вражескому флоту. Для того, чтобы сделать это - создаём метод printField.

static void printField(char[][] field) {
        for (char[] cells : field) {
            for (char cell : t) {
              // если значение дефолтовое (в случае char - 0), значит в данной клетке
              // корабль не установлен - печатаем пустую клетку
                if (cell == 0) {
                    System.out.print(" |");
                } else {
   									// если клетка непустая (значение отличается от дефолтового),
                  	//тогда отрисовываем сожержимое клетки (элемента массива)
                    System.out.print(cell + "|");
                }
            }
            System.out.println("");
            System.out.println("--------------------");
        }
    }

На экране метод будет так отображать расстановку кораблей:

 | | | | | | | | | |
--------------------
 |1|1|1|1| | | | | |
--------------------
 | | | | | | | | | |
--------------------
 | | | | | | | | | |
--------------------
 | | | | | | | | | |
--------------------
 | | | | | | | | | |
--------------------
 | | | | | | | | | |
--------------------
 | | | | | | | | | |
--------------------
 | | | | | | | | | |
--------------------
 | | | | | | | | | |
--------------------

Вот игроки уже заполнили свои корабли на карте, и теперь мы можем приступить к реализации пунктов 8-10 бизнес-требований заказчика.

Логику по получению от пользователя координат выстрела, обработки выстрела и передачи хода опишем в методе playGame. Дабы придерживаться (или пока только стараться) принципа single responsobility - не забываем делить логику на методы (1 функциональность - 1 метод, но тоже держим себя в руках, код, в котором 100500 однострочных методов тоже не комильфо) - примерно из этих соображений получились еще методы handleShot и isPlayerAlive. Реализация обоих приведена ниже

/**
* Метод реализует логику игры: выстрел и передача хода.
*/
private static void playGame(String player1Name, String player2Name, char[][] playerField1, char[][] playerField2) {
        // "карты" выстрелов - создаём двумерные массивы, которые содержат все выстрелы
  			// удачные (#) и неудачные (*)
  			char[][] playerBattleField1 = new char[FILED_LENGTH][FILED_LENGTH];
        char[][] playerBattleField2 = new char[FILED_LENGTH][FILED_LENGTH];

  			// вспомогательные переменные, которым будут присваиваться значения текущего игрока - 
  			// игрока, чья очередm делать выстрел. Сначала играет первый игрок, прошу прошения
  			// за тавтологию
        String currentPlayerName = player1Name;
        char[][] currentPlayerField = playerField2;
        char[][] currentPlayerBattleField = playerBattleField1;

  			// внутри цикла происходит смена очередности игроков, выстрел, его обработка.
  			// код внутри цикла выполняется до тех пор, пока "живы" оба игрока - пока у двух игроков
  			// "частично" цел (ранен) ещё хотя бы один корабль
        while (isPlayerAlive(playerField1) && isPlayerAlive(playerField2)) {
          	// принимаем от пользователя координаты выстрела
            System.out.println(currentPlayerName + ", please, input x coord of shot");
            int xShot = scanner.nextInt();
            System.out.println(currentPlayerName + ", please, input y coord of shot");
            int yShot = scanner.nextInt();

          	// обрабатываем выстрел и получаем возвращаемое значение метода handleShot
            int shotResult = handleShot(currentPlayerBattleField, currentPlayerField, xShot, yShot);
            // если выстрел неудачный, и не один корабль не повреждён, то очередь переходит к следующему игроку
          	if (shotResult == 0) {
                currentPlayerName = player2Name;
              	currentPlayerField = playerField1;
              	currentPlayerBattleField = playerBattleField2;
            }
        }
    }

		/**
    * Метод обрабатывает выстрел. Если выстрел удачный, то есть снаряд достиг цели -
    * в клетку записывается значение '#' (отображается к в массиве игрока, так и в массиве соперника),
    * а также на экран выводится сообщение 'Good shot!'. В этом случае метод возвращает значение 1.
    * В случае неудачного выстрела - в массив battleField записывается значение '0' в элемент [y][x], и
    * и возвращается значение 0.
    * Возвращаемые значения нам нужны для того, чтобы в методе, внутри которого вызывается метод handleShot,
    * мы могли понимать, успешно или неуспешно прошёл выстрел. На основе этого мы принимаем решение, 
		* переходит ход к другому игроку или нет.
    */
    private static int handleShot(char[][] battleField, char[][] field, int x, int y) {
        if ('1'.equals(field[y][x])) {
            field[y][x] = '#';
            battleField[y][x] = '#';
            System.out.println("Good shot!");
            return 1;
        }
        battleField[y][x] = '*';
        System.out.println("Bad shot!");
        return 0;
    }

		/**
		*	Метод определяет, не проиграл ли еще игрок. Если у игрока остался хотя бы
    * один "раненный" корабль, тогда пользователь продолжает игру.
    * То есть, если на карте у игрока остался хотя бы один символ '1', которым мы отмечали
    * корабли, то игра продолжается - возвращается значение true. Иначе false.
		*/
    private static boolean isPlayerAlive(char[][] field) {
        for (char[] cells : field) {
            for (char cell : cells) {
                if ('1' == cell) {
                    return true;
                }
            }
        }
        return false;
    }

Думаю, что к комментариям в коде мне добавить нечего. Единственное, обращу внимание на тонкий момент. Мы привыкли в математике к записи (x, y) - где первой идёт координат абсцисс, а второй - координата ординат. Казалось бы, чтобы обратиться к элементу двумерного массива (иногда срываюсь и называю в тексте элемент клеткой, но суть не меняется) нужно написать arr[x][y], но это будет неверно, и чтобы это доказать воспользуемся неопрвергаемым методом пристального взгляда. Для примера рассмотрим следующий двумерный массив:

int[][] arr = {{1, 2}, {7, 4}, {8, 3, 5, 9}, {1}}

System.out.println(arr[0][1]); // ?
System.out.println(arr[1][0]); // ?

Теперь вопрос из квиза "Программирование и мир" - что выведется на консоль в строках 3 и 4?
Вспоминаем, что двумерный массив - это не совсем таблица (нам так проще его воспринимать и детектировать его в задачах) - это "массив массивов" - вложенные массивы. Если в одномерных целочисленных массивах элементом является целое число, то в случае двумерного массива - элементом является массив (а в случае трёхмерного массива - элементом является двумерный массив). Таким образом, первый индекс указывает, какой по счёту массив мы выбираем. Второй индекс указывает, какой элемент по счёту мы выбираем в выбранном ранее массиве. Запись arr[1][2] указывает, что мы обращаемся к элементу с индексом 2 (то есть 3 по порядку) в массиве с индексом 1 (или второму по порядку). Соответсвенно, в строке 3 в консоль выведется значение 2, а в строке 4 - 7.

Постепенно подбираемся к концу. Что нам осталось реализовать?

  1. Вывод имени победителя

  2. Проверка клетки, которую пользователь указал как начало корабля

Первое кажется проще, стартанём с него. Потопали в метод playGame - как вы помните, там есть цикл while, в условии которого есть проверка - живы ли еще оба игрока. Напомню, что если игрок "мёртв", то есть у него не осталось ни одного корабля, то игра прекращается, а выживший игрок считается победителем. Собственно, единственное, что добавилось - строчка 36 - вызов метода System.out.println()

/**
* Метод реализует логику игры: выстрел и передача хода.
*/
private static void playGame(String player1Name, String player2Name, char[][] playerField1, char[][] playerField2) {
		// "карты" выстрелов - создаём двумерные массивы, которые содержат все выстрелы
    // удачные (#) и неудачные (*)
    char[][] playerBattleField1 = new char[FILED_LENGTH][FILED_LENGTH];
    char[][] playerBattleField2 = new char[FILED_LENGTH][FILED_LENGTH];

    // вспомогательные переменные, которым будут присваиваться значения текущего игрока - 
    // игрока, чья очередm делать выстрел. Сначала играет первый игрок, прошу прошения
    // за тавтологию
    String currentPlayerName = player1Name;
    char[][] currentPlayerField = playerField2;
    char[][] currentPlayerBattleField = playerBattleField1;

    // внутри цикла происходит смена очередности игроков, выстрел, его обработка.
    // код внутри цикла выполняется до тех пор, пока "живы" оба игрока - пока у двух игроков
    // "частично" цел (ранен) ещё хотя бы один корабль
    while (isPlayerAlive(playerField1) &amp;&amp; isPlayerAlive(playerField2)) {
      	// перед каждым выстрелом выводим в консоль отображение всех выстрелов игрока
      	printField(currentPlayerBattleField);
        // принимаем от пользователя координаты выстрела
        System.out.println(currentPlayerName + ", please, input x coord of shot");
        int xShot = scanner.nextInt();
        System.out.println(currentPlayerName + ", please, input y coord of shot");
        int yShot = scanner.nextInt();

        // обрабатываем выстрел и получаем возвращаемое значение метода handleShot
        int shotResult = handleShot(currentPlayerBattleField, currentPlayerField, xShot, yShot);
        // если выстрел неудачный, и не один корабль не повреждён, то очередь переходит к следующему игроку
          if (shotResult == 0) {
            currentPlayerName = player2Name;
              currentPlayerField = playerField1;
              currentPlayerBattleField = playerBattleField2;
        }
    }
  	System.out.println(currentPlayerName + " is winner!");
}

Переходим ко второму пункту наших "остатков" - реализуем проверку клетки, которую указал пользователь - начало корабля.

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

Окрестность с радиусом одна клетка. 
Красным кругом помечена стартовая клетка - начало корабля.
Окрестность с радиусом одна клетка. Красным кругом помечена стартовая клетка - начало корабля.

Предлагаю простую версию алгоритма - обходим все клетки, в которых предполагаем разместить корабль, и проверяем, есть ли в окрестности с радиусом один каждой клетки другие корабли. Если хотя бы для одной клетки условие не выполнилось, то есть есть на поле корабль, который находится "слишком" рядом с планируемым - сообщаем пользователю о проблеме и просим его ввести новые координаты.

Какие нюансы есть в этом алгоритме? Снова посмотрим на рисунок.

Неэффективность нашего простого алгоритма. 
Некоторые клетки проверяем несколько раз.
Неэффективность нашего простого алгоритма. Некоторые клетки проверяем несколько раз.
private static int validateCoordForShip(char[][] field, int x, int y, int position, int shipType) {
        // если пользователь хочет расположить корабль горизонтально
  			if (position == 1) {
            for (int i = 0; i < shipType - 1; i++) {
								if ('1' == field[y][x + i]
                                || '1' == field[y - 1][x + i]
                                || '1' == field[y + 1][x + i]
                                || '1' == field[y][x + i + 1]
                                || '1' == field[y][x + i - 1]
																|| (x + i) > 9) {
                    return -1;
                }
            }
        } else if (position == 2) {
          	// если пользователь хочет расположить корабль вертикально
            for (int i = 0; i < shipType - 1; i++) {
                if ('1' == field[y][x + i]
                        || '1' == field[y - 1][x + i]
                        || '1' == field[y + 1][x + i]
                        || '1' == field[y][x + i + 1]
                        || '1' == field[y][x + i - 1]
												|| (y + i) > 9) {
                    return -1;
                }
            }
        }
        return 0;
    }

Не забываем проапгрейдить метод fillPlayerField - напомню, что в этом методе реализована логика по расположению кораблей игроками.

private static void fillPlayerField(char[][] playerField) {
        for (int i = 4; i >= 1; i--) {
            // растановка кораблей
            for (int k = i; k <= 5 - i; k++) {
                System.out.println("Расставляем " + i + "-палубный корабль. Осталось расставить: " + (q + 1));
								
              	// иницализируем переменную начальным значением
              	int validationResult = 1;
            		while (validationResult != 0) {
              			System.out.println("Input x coord: ");
            				x = scanner.nextInt();

            				System.out.println("Input y coord: ");
           		 			y = scanner.nextInt();

            				System.out.println("1 - horizontal; 2 - vertical ?");
            				position = scanner.nextInt();
                  	// если координата не прошла валидацию (проверку), то метод возвращает отрицательное
										// значение, конечно, оно не равно нулю, поэтому пользователю придётся ввести координаты
                  	// ещё раз
                  	validationResult = validateCoordForShip(playerField, x, y, position, i);
                }

            		// если корабль располагаем горизонтально
              	if (position == 1) {
                		// заполняем '1' столько клеток по горизонтали, сколько палуб у корабля
                		for (int q = 0; q < i; q++) {
                    		playerField[y][x + q] = '1';
                		}
            		}
          
              	// если корабль располагаем вертикально
            		if (position == 2) {
                  	// заполняем столько клеток по вертикали, сколько палуб у корабля
                		for (int m = 0; m < i; m++) {
                    		playerField[y + m][x] = '1';
                		}
            		}
              	// печатаем в консоли поле игрока, на котором будет видно, где игрок уже поставил корабли
              	// о реализации метода - см. ниже
            		printField(playerField);
        		}
    		}
}

Вот мы и написали игру "Морской бой" - level 1. Начали с простого - простых конструкций, идей и методов. Один класс, нет коллекций - только массивы. Оказывается на циклах и массивах можно написать игру.

Мы удовлетворили все требования бизнеса. Доигрывая до конца, получили отличную оценку от заказчика, он полностью доволен приложением. Ждём, когда он опробует игру и вернётся снова за апгрейдом. А тут и будет level - 2.

Всем спасибо, всегда рад обратной связи!

Теги:
Хабы:
Всего голосов 5: ↑4 и ↓1+3
Комментарии3

Публикации

Истории

Работа

Java разработчик
362 вакансии

Ближайшие события