Консольная игра «Крестики-нолики»

36. Реализовать консольную игру «Крестики-нолики» произвольного размера

Условие задачи:
Необходимо реализовать консольную игру «Крестики-нолики» с произвольным размером квадратного поля.

Структура программы:

  • Main — запрашивает размер поля и запускает игру;

  • Game — управляет игровым циклом и определяет победу или ничью;

  • Desk — хранит состояние игрового поля;

  • AI — интерфейс участника игры;

  • Player — получает координаты хода от пользователя;

  • IndianAI — делает случайный допустимый ход.

Игрок X и компьютер O ходят по очереди.

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


Спойлеры к решению

Подсказки
💡 Desk должен самостоятельно знать размер поля — получать его через reflection не нужно.
💡 Лучше предоставить методы getSize(), place(), hasWon() и isFull().
💡 Участник игры должен уметь сделать ход и сообщить свой символ.
💡 Game отвечает только за последовательность ходов и завершение игры.
💡 Для случайного хода компьютера можно сначала определить количество свободных клеток, а затем выбрать одну из них случайно.

Решение

AI задаёт общий контракт для участника игры:

public interface AI {
    void makeMove(Desk desk);
    char getSymbol();
}

Desk хранит состояние игрового поля:

public class Desk {

    private static final char EMPTY = '.';

    private final char[][] field;
    private final int size;

    private int occupiedCells;

    public Desk(int size) {
        if (size <= 0) {
            throw new IllegalArgumentException(
                    "Размер поля должен быть больше 0"
            );
        }

        this.size = size;
        this.field = new char[size][size];

        for (int row = 0; row < size; row++) {
            Arrays.fill(field[row], EMPTY);
        }
    }

    public int getSize() {
        return size;
    }

    public boolean isCellEmpty(int row, int col) {
        return isInside(row, col)
                && field[row][col] == EMPTY;
    }

    public boolean place(int row, int col, char symbol) {
        if (!isCellEmpty(row, col)) {
            return false;
        }

        field[row][col] = symbol;
        occupiedCells++;
        return true;
    }

    public boolean isFull() {
        return occupiedCells == size * size;
    }

    public boolean hasWon(char symbol) {
        for (int i = 0; i < size; i++) {
            if (checkRow(i, symbol)
                    || checkColumn(i, symbol)) {
                return true;
            }
        }

        return checkMainDiagonal(symbol)
                || checkSecondaryDiagonal(symbol);
    }

    private boolean checkRow(int row, char symbol) {
        for (int col = 0; col < size; col++) {
            if (field[row][col] != symbol) {
                return false;
            }
        }

        return true;
    }

    private boolean checkColumn(int col, char symbol) {
        for (int row = 0; row < size; row++) {
            if (field[row][col] != symbol) {
                return false;
            }
        }

        return true;
    }

    private boolean checkMainDiagonal(char symbol) {
        for (int i = 0; i < size; i++) {
            if (field[i][i] != symbol) {
                return false;
            }
        }

        return true;
    }

    private boolean checkSecondaryDiagonal(char symbol) {
        for (int i = 0; i < size; i++) {
            if (field[i][size - 1 - i] != symbol) {
                return false;
            }
        }

        return true;
    }

    private boolean isInside(int row, int col) {
        return row >= 0 && row < size
                && col >= 0 && col < size;
    }

    public void print() {
        for (char[] row : field) {
            for (char cell : row) {
                System.out.print(cell + " ");
            }

            System.out.println();
        }

        System.out.println();
    }
}

Живой игрок получает координаты из консоли:

public class Player implements AI {

    private final Scanner scanner;
    private final char symbol;

    public Player(Scanner scanner, char symbol) {
        this.scanner = scanner;
        this.symbol = symbol;
    }

    @Override
    public void makeMove(Desk desk) {
        while (true) {
            System.out.printf(
                    "Ход игрока '%c' (строка и столбец от 1 до %d): ",
                    symbol,
                    desk.getSize()
            );

            int row = scanner.nextInt() - 1;
            int col = scanner.nextInt() - 1;

            if (desk.place(row, col, symbol)) {
                return;
            }

            System.out.println(
                    "Некорректный ход: клетка занята или находится за пределами поля."
            );
        }
    }

    @Override
    public char getSymbol() {
        return symbol;
    }
}

Компьютер выбирает случайную свободную клетку:

public class IndianAI implements AI {

    private final char symbol;

    public IndianAI(char symbol) {
        this.symbol = symbol;
    }

    @Override
    public void makeMove(Desk desk) {
        int emptyCells = desk.getSize() * desk.getSize();

        for (int row = 0; row < desk.getSize(); row++) {
            for (int col = 0; col < desk.getSize(); col++) {
                if (!desk.isCellEmpty(row, col)) {
                    emptyCells--;
                }
            }
        }

        if (emptyCells == 0) {
            throw new IllegalStateException("Свободных клеток нет");
        }

        int target = ThreadLocalRandom.current()
                .nextInt(emptyCells);

        int current = 0;

        for (int row = 0; row < desk.getSize(); row++) {
            for (int col = 0; col < desk.getSize(); col++) {
                if (!desk.isCellEmpty(row, col)) {
                    continue;
                }

                if (current == target) {
                    desk.place(row, col, symbol);

                    System.out.printf(
                            "Ход компьютера '%c': %d %d%n",
                            symbol,
                            row + 1,
                            col + 1
                    );

                    return;
                }

                current++;
            }
        }
    }

    @Override
    public char getSymbol() {
        return symbol;
    }
}

Game управляет очередностью ходов:

public class Game {

    private final Desk desk;
    private final AI playerX;
    private final AI playerO;

    public Game(int size, AI playerX, AI playerO) {
        this.desk = new Desk(size);
        this.playerX = playerX;
        this.playerO = playerO;
    }

    public void play() {
        desk.print();

        while (true) {
            if (makeMove(playerX)) {
                return;
            }

            if (makeMove(playerO)) {
                return;
            }
        }
    }

    private boolean makeMove(AI player) {
        player.makeMove(desk);
        desk.print();

        if (desk.hasWon(player.getSymbol())) {
            System.out.println(
                    "Победил '" + player.getSymbol() + "'!"
            );
            return true;
        }

        if (desk.isFull()) {
            System.out.println("Ничья!");
            return true;
        }

        return false;
    }
}

Точка входа:

public class Main {

    public static void main(String[] args) {
        try (Scanner scanner = new Scanner(System.in)) {
            System.out.print("Введите размер поля: ");

            int size = scanner.nextInt();

            Game game = new Game(
                    size,
                    new Player(scanner, 'X'),
                    new IndianAI('O')
            );

            game.play();
        }
    }
}

Пример запуска:

Введите размер поля: 3

. . .
. . .
. . .

Ход игрока 'X' (строка и столбец от 1 до 3): 1 1

X . .
. . .
. . .

Ход компьютера 'O': 2 2

X . .
. O .
. . .

Desk полностью инкапсулирует состояние поля: другие классы не получают прямого доступа к массиву.

Метод:

public boolean place(int row, int col, char symbol)

одновременно проверяет возможность хода и изменяет поле. Это не позволяет случайно перезаписать уже занятую клетку.

Для проверки победы просматриваются все строки, столбцы и две диагонали. Для поля N × N такая проверка работает за O(N²).

isFull() работает за O(1), поскольку количество занятых клеток хранится отдельно.

Случайный ход компьютера занимает O(N²) и выбирает одну из свободных клеток без риска бесконечно долго генерировать уже занятые координаты.