Крестики-нолики

Крестики нолики всем известная игра. Давайте реализуем ее.
Крестики-нолики интересна нам с связи с тем, что является весьма простой (простое игровое поле всего 3 на 3 ячейки), но в том же время полной законченной мини-игрой с наличием двух игроков, условий победы и поражения.
Игровое поле: квадратная сетка 3 на 3 ячейки, каждая ячейка может принимать 3 состояния, либо она пуста, либо в ней устанволен
Условия победы: расположить в линию 3 подряд своих знака (крестиков или ноликов)
Условия окончания игры: либо один из игроков победил, либо не осталось свободных ячеек для хода
Объявим переменные для представления игры:
int[,] board = new int[3, 3]; // игровое поле
bool gameOver = false; // признак окончания игрыТакже объъявим дополнительные переменные для отрисовки игры:
const int CellGap = 25; // зазор внутри ячейки до крестика (или нолика) в пикселях
const int WinLineGap = 10; // зазор победной линии
const int CellWidth = 100; // ширина одной ячейки в пикселях
Random rand = new Random(); // генератор псевдо-случайных чисел
const int PenThickness = 5; // толщина линий
Pen borderPen = new Pen(Color.Silver, PenThickness); // карандаш границы
Pen winLinePen = new Pen(Color.White, PenThickness) // карандаш победной линии
{
// делать скругление на обох концах
EndCap = LineCap.Round,
StartCap = LineCap.Round
};
Pen penX = new Pen(Color.LimeGreen, PenThickness) // карандаш крестика
{
// делать скругление на обох концах
EndCap = LineCap.Round,
StartCap = LineCap.Round
};
Pen penO = new Pen(Color.Red, PenThickness); // карандаш для нолика
Напишем конструктор:
public Form1()
{
InitializeComponent();
// включам двойную буфферизацию, что исключить мерцание (фликеринг)
DoubleBuffered = true;
// стартовая позиция окна - по центру экрана
StartPosition = FormStartPosition.CenterScreen;
// жестко зададим размер окна, привяжем еще к размеру ячеек
ClientSize = new Size(CellWidth * 3, CellWidth * 3);
// подпишемся на событие отрисовки формы
Paint += Form1_Paint;
// создадим таймер отрисовки сцены
var timer = new System.Windows.Forms.Timer();
timer.Interval = 10;
timer.Tick += Timer_Tick;
timer.Start();
//запретим максимизировать окно
MaximizeBox = false;
//запретим изменять размер окна
FormBorderStyle = FormBorderStyle.FixedSingle;
//подпишемся на событие нажатия мышки
MouseUp += Form1_MouseUp;
//сбросим игру
ResetGame();
}
Напишем функцию сброса игры
private void ResetGame()
{
gameOver = false; // сбрасываем флаг окончания игры
// проходимся по игровому полю и очищаем его
for (int i = 0; i < 3; i++)
for (int j = 0; j < 3; j++)
board[i, j] = 0;
// с вероятностью ~50% компьютер делает первых ход
if (rand.Next(100) > 50)
ComputerMove();
}
Напишем функцию отрисовки поля:
public void DrawBoard(Graphics gr)
{
for (int i = 0; i < 3; i++) // пройдемся по 3 столбцам (X)
{
for (int j = 0; j < 3; j++) // пройдемся по 3 строкам (Y) в каждом столбце
{
// рисуем обводку ячейки
gr.DrawRectangle(borderPen, i * CellWidth, j * CellWidth, CellWidth, CellWidth);
if (board[i, j] == 1) // рисуем X
DrawX(gr, i, j);
else if (board[i, j] == 2) // рисуем O
DrawO(gr, i, j);
}
}
}
Функция отрисовки крестика
private void DrawX(Graphics gr, int i, int j)
{
// рисуем две линии крестика с нужными отступами
gr.DrawLine(penX, i * CellWidth + CellGap, j * CellWidth + CellGap, (i + 1) * CellWidth - CellGap, (j + 1) * CellWidth - CellGap);
gr.DrawLine(penX, i * CellWidth + CellGap, (j + 1) * CellWidth - CellGap, (i + 1) * CellWidth - CellGap, j * CellWidth + CellGap);
}
Функция отрисовки нолика:
private void DrawO(Graphics gr, int i, int j)
{
gr.DrawEllipse(penO, i * CellWidth + CellGap, j * CellWidth + CellGap, CellWidth - CellGap * 2, CellWidth - CellGap * 2);
}
Напишем игровой цикл:
private void Form1_Paint(object? sender, PaintEventArgs e)
{
var gr = e.Graphics;
gr.SmoothingMode = SmoothingMode.AntiAlias;
// проверяем окончена игра или нет
gameOver = IsGameOver();
// очищаем область рисования черным цветом
gr.Clear(Color.Black);
//рисуем игровое поле (обводка, крестики и нолики)
DrawBoard(gr);
// рисуем линию в случае победы одного из игроков
DrawWinLine(gr);
}
Добавляем ходы игроков при нажатии мышки
При нажатии мышкой будет ставить крестик в пустое поле, после этого если остаются пустые клетки на поле, то будет давать сделать ход компьютеру.
Функция обработки нажатия мыши:
private void Form1_MouseUp(object? sender, MouseEventArgs e)
{
if (gameOver) // если игра окончена и нажали кнопку мыши, то сбросить игру
{
ResetGame();
return;
}
// вычисляем колонку и строку куда нажали
var col = e.Location.X / CellWidth;
var row = e.Location.Y / CellWidth;
// если нажатая ячейка не пустая, то выходим
if (board[col, row] != 0)
return;
// ставим крестик в пустую нажтую ячейку
board[col, row] = 1;
if (!IsGameOver()) // если игра не окончена
ComputerMove(); // то компьютер делает ход
}
Напишем функцию которая будет подсчитывать сколько свободных ячеек осталось на поле:
private int FreeCells()
{
int count = 0; //аккумулятор
//проходим по игровому полю двумя циклами
for (int i = 0; i < 3; i++)
{
for (int j = 0; j < 3; j++)
{
if (board[i, j] == 0)
count++; // если ячейка пустая то инкрементируем аккумулятор
}
}
return count; // возвращаем посчитанное количество
}
Добавляем проверку зачекрнутости линий
Для того чтобы проверять условия что какая либо из линий содержит по 3 одинаковых элемента, давайте напишем отдельные функции для проверки заполненности строки, столбца, диагонали и обратной диагонали
bool AreNumbersEqual(int v1, int v2, int v3) => v1 == v2 && v2 == v3;
bool IsRowWin(int row) => board[0, row] != 0 && AreNumbersEqual(board[0, row], board[1, row], board[2, row]);
bool IsColumnWin(int column) => board[column, 0] != 0 && AreNumbersEqual(board[column, 0], board[column, 1], board[column, 2]);
bool IsDiagWin() => board[0, 0] != 0 && AreNumbersEqual(board[0, 0], board[1, 1], board[2, 2]);
bool IsBackDiagWin() => board[2, 0] != 0 && AreNumbersEqual(board[2, 0], board[1, 1], board[0, 2]);
Как вы видите каждая функция в отдельности является весьма примитивной. Все они используют вспомогательную функцию AreNumbersEqual котороая проверяет что три переданных в нее числа равны друг другу. Обратите внимание ,что для записи функций мы использовали упрощенный способ объявления методов (вместо скобок и return используется стрелка =>), это позволяет сократить запись, чтобы все выглядело более лакончино.
Используя эти функции давайте напишем функцию проверки окончания игры. Окончанием игры считается если какая либо из линия заполнена либо если не осталось свободных ячеек для совершения хода
private bool IsGameOver()
{
// проверим есть ли заполненные строка или столбец
for (int i = 0; i < 3; i++)
if (IsRowWin(i) || IsColumnWin(i))
return true;
// проверяем обе диагонали
if (IsDiagWin() || IsBackDiagWin())
return true;
// проверяем количество свободных ячеек
if (FreeCells() == 0)
return true;
return false;
}
Добавляем ход компьютера
Напишем функцию которая будет ставить нолик в случайную пустую ячейку нашего игрового поля
void ComputerMove()
{
int x;
int y;
// проверяем сколько пустых ячеек на поле
int qty = FreeCells();
if (qty == 0) // если пустых ячеек не осталось, то выходм ничего не делая
return;
do //генерируем в цикле случайные позиции до тех пор пока не найдем свободную
{
x = rand.Next(3);
y = rand.Next(3);
} while (board[x, y] != 0);
board[x, y] = 2; // устанавливаем нолик в свободную ячейку
}
Добавим отрисовку победных линий (который зачеркивают выигрышную серию значков)
void DrawWinLine(Graphics gr)
{
for (int i = 0; i < 3; i++)
{
if (IsRowWin(i))
gr.DrawLine(winLinePen, WinLineGap, i * CellWidth + CellWidth / 2, ClientSize.Width - WinLineGap, i * CellWidth + CellWidth / 2);
if (IsColumnWin(i))
gr.DrawLine(winLinePen, i * CellWidth + CellWidth / 2, WinLineGap, i * CellWidth + CellWidth / 2, ClientSize.Height - WinLineGap);
}
if (IsDiagWin())
gr.DrawLine(winLinePen, WinLineGap, WinLineGap, ClientSize.Width - WinLineGap, ClientSize.Height - WinLineGap);
if (IsBackDiagWin())
gr.DrawLine(winLinePen, WinLineGap, ClientSize.Height - WinLineGap, ClientSize.Width - WinLineGap, WinLineGap);
}
Запустим и проверим что получилось

Полный код урока смотри здесь
Задания:
1. Ввести явную машину состояний игры, с помощью перечисления например такую
public enum GameState
{
PlayerMove,
ComputerMove,
GameOver
}Удалить переменную gameOver, ввести переменную текущего состояния игры (GameState state), пределать всю логику игры на использование машины состояний.
2. Увеличить размер поля до 5х5
3. Сделать большие крестики нолики, размер поля 9х9 , для победы нужно чтобы линии было 5 подряд крестиков или ноликов
4. Сделать Супер крестики нолики, с 9 полями, более подробно читай в википедии
5. Добавить подсчет очков и выводить их на экран.
6. Сделать чтобы в игру можно было играть вдвоем, передавая мышку на каждом шагу (hotseat)
7. Сделать игровое меню чтобы при старте игры можно было выбирать режим игры: игра с компьютером либо игра со вторым игроком
8. Сделать сетевую игру
9. Ввести перечисление для состояние ячейки (ячейка пустая, установлен Х, установле О) и заменить всю логику игры на использование этого перечисления