Сетевая игра Крестики-Нолики

Все исходники /  Язык программирования C# /  OS Windows /  Desktop /  Исходники игр / Сетевая игра Крестики-Нолики

Сетевой протокол UDP

Сетевой протокол UDP - User Datagram Protocol, стандарт отправки пользовательских (как правило, небольших) блоков данных. UDP характеризуется как быстрый протокол, не затрачивающий время на установку соединения, работает по правилу: отправил и забыл. UDP не гарантирует порядок принятия датаграмм, но гарантирует целостность принятых пакетов байтов.

Быстрый UDP менее надёжен чем протоколы устанавливающие соединения. Но, в настоящее время, надежность работы по стандарту UDP существенно повышается благодаря устойчивым сетевым соединениям. Поэтому создание сетевой игры на основе протокола UDP не такая уж и сомнительная затея.

Программный код сетевой игры Крестики-Нолики

Игра Крестики-Нолики, Tic-Tac-Toe

Исходный код сетевой игры Крестики-Нолики написан на языке C#, платформа .NET6. Обмен данными между игровыми приложениями производится по протоколу UDP. Функциональность сетевого обмена построена на объектах высокоуровневого класса C# UdpClient. Класс UdpClient значительно упрощает построение кода сетевой работы, и в принципе, программный код сводится к использованию двух методов: UdpClient.Send(...) и UdpClient.Receive(...).

В приложении-игре используются два клиента UDP: один осуществляет непрерывное прослушивание сообщений, другой занимается отправкой. В виду очень малого размера отправляемых сообщений отправка осуществляется в блокирующем режиме в главном потоке приложения. Отправления происходят быстро и не вызывают заметного зависания интерфейса игры.

В теле метода Send(DataChange change) присутствует программный код двух версий преобразования сообщений в байты: при помощи классов двоичной сериализации BinaryFormatter и текстовой xml-сериализации XmlSerializer. Соответственно аналогичные версии имеются в методе приема данных.

// Класс обмена данных по сети.
[Serializable]
public class DataChange
{
    public DataType gameState = DataType.Undefined;
    public byte X;
    public byte Y;
    public byte type;
}

// Тип отправляемого и принимаемого сообщения
public enum DataType : byte
{
    Undefined,
    // Начало игры (приглашение на игру)
    Start,
    // Игра в процессе
    Game,
    // Победа одного из игроков
    Victory,
    // Ничья
    Draw,
    // Игрок производит ход
    PlayerMove,
    // Клиент закрыл приложение-игру
    AppClose
}

// Отправка данных по сети с двумя версиями 
// сериализации.
private void Send(DataChange change)
{
    // Версия 1
    MemoryStream memoryStream = new();
    BinaryFormatter bf = new();
    bf.Serialize(memoryStream, change);


    // Версия 2
    /*
    XmlSerializer serializer = new(typeof(DataChange));
    MemoryStream memoryStream = new();
    XmlWriter xmlWriter = XmlWriter.Create(memoryStream);
    serializer.Serialize(xmlWriter, change);
    */

    byte[] sent = memoryStream.ToArray();

    // Для создания объекта класса UdpClient используем 
    // конструктор по умолчанию
    UdpClient udp = new();

    // Указываем объекту реквизиты  удаленной точки
    // и отправляем сообщение, удаленный ip адрес 
    // можно изменять в текстовом боксе.
    // Данные удаленного клиента, которому отсылаются байты.
    IPEndPoint ep = new(IPAddress.Parse(tbIPRemote.Text), int.Parse(tbPortRemote.Text));
    udp.Connect(ep);
    udp.Send(sent, sent.Length);

    // Закрываем UDP соединение
    udp.Close();
}

Прослушивание сообщений происходит во вторичном потоке и не оказывает блокирующее действие на главное окно. Запуск прослушивания происходит в приложении однократно и данный процесс работает до закрытия приложения игры. Принятые байты могут расшифровываться в объект класса DataChange двумя способами десериализации: двоичной и текстовой. После успешного приема принятые сообщения передаются в метод ParseReceive(DataChange change) для анализа и исполнения действий.

При закрытии приложения происходит принудительная отмена блокирующего режима сокета с выбросом исключения SocketError.Interrupted(аналог в Windows Sockets Win32 - WSAEINTR). Реакция на ожидаемое исключение отменяется блоками try-catch.

private void StartReceive()
{
    // Чтобы основной поток приложения не блокировался,
    // для извлечения сообщений запускаем дополнительный поток.
    ThreadStart tstart = new(Receive);
    threadReceive = new Thread(tstart);
    threadReceive.Start();
}


// Функция запускаемая из дополнительного потока
// для цикличного процесса извлечения сообщений
private void Receive()
{
    try
    {
        // Клиент слушает сообщения приходящие на адрес хоста.
        // В данном случае прослушиваются адреса всех сетевых карт машины.
        IPEndPoint ep = new(IPAddress.Any, int.Parse(tbPortHost.Text));
        udp = new UdpClient(ep);
        while(udp != null)
        {
            IPEndPoint? remote = null;
            byte[] recv = udp.Receive(ref remote);

            // Версия 1
            MemoryStream memoryStream = new(recv);
            BinaryFormatter bf = new();
            DataChange? change = (DataChange)bf.Deserialize(memoryStream);

            // Версия 2
            /* XmlSerializer serializer = new(typeof(DataChange));
             MemoryStream memoryStream = new(recv);
             XmlReader xmlReader = XmlReader.Create(memoryStream);
             DataChange? change = (DataChange?)serializer.Deserialize(xmlReader);*/

            // После получения сообщения происходит его анализ.
            if (change != null) ParseReceive(change);
        }
    }
    // Когда операция блокирования прерывается закрываем UDP клиент.
    catch (SocketException e) when (e.ErrorCode == (int)SocketError.Interrupted)
    {
        
    }
    catch { }
    finally
    {
        // В данном месте исходного кода можно 
        // отслеживать возникающие исключения
        if (udp != null)
        {
            udp.Close();
            udp = null;
        }
    }
}

Процедуры обмена данных по сети

Алгоритм сетевого обмена данными игры Крестики-Нолики построен на нескольких логических операциях:

  • инициализация игры
  • отправка данных хода игрока
  • отправка сообщения окончания игры в случае победы одного из игроков
  • отправка сообщения окончания игры в случае ничьи
  • отправка сообщения о выходе из игры одного из игроков

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

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

Сообщения окончания игры в случае победы. После каждого хода игрока происходит проверка на окончание игры. Как только, какой-либо, игрок полностью заполнит ряд, столбец или диагональ своими фигурками, генерируется событие окончание игры с победой, игра останавливается и сообщение об окончании игры отправляется другому игроку. Чтобы продолжить игру достаточно одному из игроков нажать кнопку Начать игру.

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

Сообщение о выходе из игры одного из игроков. Перед закрытием окна игры текущего игрока отправляется сообщение с меткой о выходе из игры. У другого игрока игра останавливается и его приложение становится готовым к приему нового игрока.

Программный код инициализации игры

Инициализация игры начинается с метода клика кнопки Начать игру. В этом методе запускается таймер отправки сообщения начала игры. Игроки отправляют друг другу приглашения в игру. Сообщения о начале игры периодически отправляются до первого приёма приглашения одним из игроков.

Для некоторой степени непредсказуемости выбора первоходящего игрока таймер может стартовать либо с коротким интервалом, либо с длинным интервалом. Интервал выбирается генератором псевдослучайных чисел - объектом класса C# Random. В результате работы такого алгоритма игроки узнают о праве первого хода только после начала игры.

private void BtnStartGame_Click(object sender, EventArgs e)
{
    . . .

    // Случайные интервалы работы таймера
    // для непредсказуемости выбора начинающего игрока.
    int[] timeIntervals = new[] { 500, 3000 };
    timer.Interval = timeIntervals[new Random().Next(timeIntervals.Length)];

    // Запускаем таймер организации запуска игры
    timer.Start();
}

private void Timer_Tick(object? sender, EventArgs e)
{
    // Отправление сообщения приглашения в игру.
    SendInitGame();

    // Имитация активности ожидания подключения игрока.
    Console.Beep(2000, 5);
}

// Отправка приглашения в игру.
private void SendInitGame()
{
    // Клиент отправляет запрос приглашения в игру.
    DataChange datachange = new()
    {
        // Состояние игры - запуск
        gameState = DataType.Start
    };

    // Отправка сообщения на приглашение в игру.
    Send(datachange);
}

Принятые сообщения обрабатываются методом ParseReceive(DataChange change). Как только один из игроков получит сообщение о начале игры, он ответно высылает сообщение о том, что игра началась. Сообщения о начале игры принимаются однократно, после этого таймеры-инициализаторы игроков останавливаются. Первоходящий игрок ходит крестиком, другому игроку достаётся нолик.

void ParseReceive(DataChange change)
{
    if (this.displayBoard.InvokeRequired)
    {
        ReceiveCallback dt = new(ParseReceive);
        this.Invoke(dt, new object[] { change });
    }
    else
    {
        switch (change.gameState)
        {
            // Получено сообщение старта игры (приглашение в игру)
            case DataType.Start:
                {
                    // После принятия приглашения на игру
                    // останавливаем таймер отправки приглашений
                    // текущего игрока.
                    timer.Stop();

                    // Исключение повторной инициализации игры
                    // во время процесса работы таймера
                    // приглашения на игру.
                    // Однократный вход в игру.
                    if (GAMESTATE != DataType.Game)
                    {
                        // Игрок будет играть ноликом.
                        StateGame(false, "Игра началась! Ходит другой игрок!");

                        // Отправляем сообщение-согласие на игру.
                        change.gameState = DataType.Game;
                        Send(change);
                    }
                    //else MessageBox.Show("GameState.Start");
                }
                break;

            // Получен ответ-согласие на игру.
            case DataType.Game:

                // После принятия ответа на приглашение на игру
                // останавливаем таймер отправки приглашений.
                timer.Stop();

                // Исключение повторной инициализации игры
                // во время процесса работы таймера
                // приглашения на игру.
                // Однократный вход в игру.
                if (GAMESTATE != DataType.Game) 
                    // Игрок будет играть крестиком.
                    StateGame(true, "Ваш ход!");

                break;

            // Получено сообщение о координатах хода другого клиента.
            case DataType.PlayerMove:
                . . .
                break;

            // Получение сигнала об окончании игры с победителем и проигравшим.
            case DataType.Victory:
                . . .
                break;

            // Получение сигнала об окончании игры с результатом - ничья.
            case DataType.Draw:
                . . .
                break;

            case DataType.AppClose:
                . . .
                break;
        }
    }
}

Организация ходов игроков

Во время своего хода игрок указывает курсором мыши ячейку, где он будет ставить фигурку крестика или нолика. В методе игрового поля GameCanvas.OnMouseDown(...) вычисляются порядковые номера ряда и столбца будущей фигурки. Затем генерируется событие хода игрока и данные фигурки отправляются для обработки в метод формы приложения GameCanvas_EventPlayerMove(...), где даётся команда на рисование фигурки полученного типа и по полученным координатам. После этого отправляется сообщение с данными фигурки другому игроку.

// Обработка события ход игрока
private void GameCanvas_EventPlayerMove(object? sender, PlayerMoveEventArgs e)
{
    // Ходы обрабатываются только в процессе игры.
    if (GAMESTATE == DataType.Game)
    {
        if (ISMOVE == true)
        {
            // Определяем какой фигурой владеет текущий игрок.
            int type = 2;
            if (gameCanvas._cross == true) type = 1;

            // Рисуем фигуру по выбранным игроком координатам.
            gameCanvas.DoDraw(e.X, e.Y, type);

            // После хода текущего игрока переход хода другому игроку.
            ISMOVE = false;

            // Подсказка о переходе права хода другому игроку.
            displayBoard.Text = "Ходит другой игрок!";

            // Отправка координат хода текущего игрока
            // другому игроку с указанием типа фигуры,
            // чтобы у другого игрока рисовалась фигура текущего игрока.
            DataChange change = new()
            {
                gameState = DataType.PlayerMove,
                X = e.X,
                Y = e.Y,
                type = (byte)type
            };

            Send(change);
        }
        else
        {
            MessageBox.Show("Ход другого игрока");
        }
    }
    else
    {
        MessageBox.Show("Игра остановлена!");
    }

}

После принятия сообщения с меткой хода игрока DataType.PlayerMove в методе ParseReceive(DataChange change) вызывается функция рисования фигурки полученного типа и по полученным координатам. Ход переходит текущему игроку и далее процедура хода игроков повторяется.

void ParseReceive(DataChange change)
{
    if (this.displayBoard.InvokeRequired)
    {
        ReceiveCallback dt = new(ParseReceive);
        this.Invoke(dt, new object[] { change });
    }
    else
    {
        switch (change.gameState)
        {
            // Получено сообщение старта игры (приглашение в игру)
            case DataType.Start: 
                . . .
                break;

            // Получен ответ-согласие на игру.
            case DataType.Game:
                . . .
                break;

            // Получено сообщение о координатах хода другого клиента.
            case DataType.PlayerMove:
                // Рисуем фигуру на игровом поле принятого типа
                // и по принятым координатам.
                gameCanvas.DoDraw(change.X, change.Y, change.type);

                // После приема координат хода клиента-адресанта,
                // право хода получает текущий клиент.
                ISMOVE = true;

                // Выводим подсказку о праве хода текущему игроку.
                displayBoard.Text = "Ваш ход!";

                break;

            // Получение сигнала об окончании игры с победителем и проигравшим.
            case DataType.Victory:
                . . .
                break;

            // Получение сигнала об окончании игры с результатом - ничья.
            case DataType.Draw:
                . . .
                break;
            case DataType.AppClose:
                . . .
                break;
        }
    }
}

Генерация событий игрового поля

После каждого хода игрока в методе игрового поля GameCanvas.OnMouseDown(MouseEventArgs e) вычисляются номера ряда и столбца будущей фигурки, происходят проверки на победу и на результат ничья. В результате этих действий и проверок генерируются события хода игрока, победы игрока и ничьи.

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

protected override void OnMouseDown(MouseEventArgs e)
{
    base.OnMouseDown(e);

    // Событие MouseDown обрабатывается только при активном поле игры. 
    // В неактивном состоянии событие хода игрока и 
    // события окончания игры не генерируются.
    if (ActiveState == false) return;

    // Вычисляем ячейку над которой пользователь щелкнул кнопкой мыши.
    for (int x = 0; x < _numcell; x++)
    {
        for (int y = 0; y < _numcell; y++)
        {
            if (e.X > x * _sizecell &&
                e.X < (x + 1) * _sizecell &&
                e.Y > y * _sizecell &&
                e.Y < (y + 1) * _sizecell)
            {
                // Координата в номерах ячейки вычислена.
                PlayerMoveEventArgs args = new()
                {
                    X = (byte)x,
                    Y = (byte)y
                };

                // Проверяем: есть ли в данной клетке уже нарисованная фигура,
                // если есть - запрещаем генерирование событий.
                Figures f = _figures[x, y];
                if (f.type != 0)
                {
                    // Звук ошибки.
                    Console.Beep(500, 200);
                    return;
                }

                // Генерация события хода пользователя с координатами расположения ячейки хода.
                // Используется в главной форме.
                EventPlayerMove?.Invoke(this, args);

                // После каждого хода проверяем игру на окончание.
                // Если какая-либо линия (ряд, столбец, одна из больших диагоналей)
                // заполнена однообразными фигурами, генерируется событие окончания игры
                // с победителем и проигравшим.
                if (SameShapesFullLine() == true)
                {
                    // Генерация события окончания игры.
                    EventGameVictory?.Invoke(this, new EventArgs());
                }

                // Проверка на окончание игры с результатом ничья.
                if (CheckDraw() == true)
                {
                    // Генерация события окончания игры.
                    EventGameDraw?.Invoke(this, new EventArgs());
                }

                return;
            }
        }
    }
}

Обработка события победы игрока

После генерирования игровым полем события победы, обработка события происходит в коде главного окна. Состояние игры устанавливается в режим победы DataType.Victory, соответствующее сообщение отправляется другому игроку, где также устанавливается режим победы и выводится информационное сообщение о результате игры. После окончания игры начать новую можно нажатием кнопки Начать игру.

// Обработка события Победа
private void GameCanvas_EventVictory(object? sender, EventArgs e)
{
    // Установка состояния игры при победе игрока.
    StateEndGame(DataType.Victory, "Вы победили!");

    // Победитель - текущий клиент отправляет оповещение 
    // другому клиенту.
    DataChange change = new()
    {
        gameState = DataType.Victory
    };
    Send(change);
}

Отправка сообщения о выходе игрока из игры

Отправка сообщения о выходе игрока из игры происходит в методе события предвещающего закрытие окна приложения. Сообщение отправляется другому игроку с меткой DataType.AppClose после процедур закрытия прослушивания и подключения вторичного потока прослушивания к главному потоку приложения.

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

private void Form1_Closing(object sender, FormClosingEventArgs e)
{
    // Перед закрытием приложения,
    // закрываем прослушивание сообщений.
    if (udp != null)
    {
        udp.Close();
        udp = null;
    }

    // Для правильного, последовательного завершения 
    // дополнительного потока подключаем его к основному потоку.
    if (threadReceive != null) threadReceive.Join();

    // Отсылаем сообщение другому игроку о выходе из игры.
    // Отсылаем после закрытия прослушивания для того
    // чтобы на одиночном приложении не было зависания.
    DataChange change = new()
    {
        gameState = DataType.AppClose
    };
    Send(change);
}

Обработка сообщения с меткой DataType.AppClose обрабатывается в ParseReceive(DataChange change). После отключения одного игрока от игры, приложение готово к приему нового игрока. Новый игрок должен установить IP-адрес компьютера оставшегося игрока и нажать кнопку Начать игру, после этого начнётся новая игра.

void ParseReceive(DataChange change)
{
    if (this.displayBoard.InvokeRequired)
    {
        ReceiveCallback dt = new(ParseReceive);
        this.Invoke(dt, new object[] { change });
    }
    else
    {
        switch (change.gameState)
        {
            // Получено сообщение старта игры (приглашение в игру)
            case DataType.Start:
                . . .
                break;

            // Получен ответ-согласие на игру.
            case DataType.Game:
                . . .
                break;

            // Получено сообщение о координатах хода другого клиента.
            case DataType.PlayerMove:
                . . .
                break;

            // Получение сигнала об окончании игры с победителем и проигравшим.
            case DataType.Victory:
                . . .
                break;

            // Получение сигнала об окончании игры с результатом - ничья.
            case DataType.Draw:
                . . .
                break;
            case DataType.AppClose:
                StateEndGame(DataType.AppClose, "Другой игрок вышел из игры!");
                break;
        }
    }
}

Руководство к игре

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

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

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

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

Исходник сетевой игры Крестики-Нолики

Полный код сетевой игры Крестики-Нолики можно посмотреть в исходном коде, прикрепленном к данной странице. Архивный файл содержит исходный код игры на языке C# и исполняемый файл приложения для тестирования игры без компиляции кода.

Скачать исходник