Повесть о создании классической RTS в домашних условиях с нуля (часть 2: «Воскрешение»)

в 10:03, , рубрики: c++, Onimod land, RTS, Земля онимодов, игровой сервер, Программирование, разработка игр, сетевое программирование, системное программирование, стратегическая игра

Повесть о создании классической RTS в домашних условиях с нуля (часть 2: «Воскрешение») - 1

Примерно год назад вышла моя статья, которую можно назвать "первой частью" данной статьи. В первой части я насколько смог подробно разобрал тернистый путь разработчика-энтузиаста, который мне удалось когда-то самостоятельно пройти от начала и до конца. Результатом этих усилий стала игра жанра RTS "Земля онимодов" созданная мною в домашних условиях без движков, конструкторов и прочих современных средств разработки. Для проекта использовались C++ и Ассемблер, ну, и в качестве основного инструмента моя собственная голова.
В этой статье я постараюсь рассказать о том, как я решил взять на себя роль «реаниматора» и попытаться «воскресить» этот проект. Много внимания будет уделено написанию собственного игрового сервера.

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

Вступление

Вся эта история уходит корнями в 1998 год, когда мир IT был совсем иным. Игра, естественно, изначально проектировалась на существующие в тот момент условия. В частности, я бы сейчас навряд ли стал использовать ассемблер для вывода графики, но в тот момент это мне казалось чуть ли ни единственным решением. Если кому-то интересно, как всё это работает (игровая механика, AI и прочее) и каким было российское «издательство» игр в конце прошлого века, отсылаю вас к первой части статьи

Также существует отдельное описание алгоритма поиска пути, который я когда-то разработал для своей RTS. Этой статье уже более 10 лет и писалась она, скорее, «для себя», но писалась достаточно подробно, чтобы я сам мог вспомнить как всё это работает. Использованное решение, на мой взгляд, обладает высокой эффективностью с точки зрения скорости работы и гарантированно строит путь до цели на клеточном поле с любой степенью сложности расположения препятствий. Ознакомиться с этим способом можно тут.

Почему я решил продолжить всю эту «эпопею»? Мне всегда было больно осознавать, что работа такого масштаба была мною в своё время просто похоронена по экономическим причинам. И когда мне показалось, что есть хоть какой-то шанс дать этой игре вторую попытку, то я, естественно, попытался это сделать. Последний год своей жизни я почти полностью посвятил этому вопросу. Благодаря в основном поддержке читателей первой части статьи, игра прошла Greenlight, и я решил со свойственной мне целеустремленностью привести всё в порядок. И именно этой моей деятельности будет посвящена данная статья. В начале я думал описать подробно весь процесс, начиная с создания собственного GUI (графический интерфейс пользователя) и заканчивая написанием игрового сервера. Но, к сожалению, обнаружил, что этой информации получается слишком много. В результате я больше внимания уделил описанию сети, так как, мне показалось, что эта тема для многих более интересна. Я постарался дать объяснения в таком виде, чтобы это можно было как-то применить на практике. Однако у меня нет уверенности, что в результате статья не получилось слегка «тяжеловатой» для понимания.

«Оболочка» для игры или то, что можно скачать и посмотреть в исходном коде C++

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

Поэтому дополнительно я снабдил статью примером, по которому желающие могут ознакомиться с некоторыми аспектами моего подхода к написанию больших проектов:
Повесть о создании классической RTS в домашних условиях с нуля (часть 2: «Воскрешение») - 2
Визуально этот пример мало похож на мою игру, но на деле игра использует именно этот код, только в ней применяется другая «оформляющая тема ». Настройка игрового сеанса выглядит в игре так:
Повесть о создании классической RTS в домашних условиях с нуля (часть 2: «Воскрешение») - 3
Также на всякий случай уточняю, что это не «кнопки Windows-а», как кто-то может подумать, а мои собственные компоненты, которые я сделал в том объеме, который требовала моя игра.

Пример является не просто «формальным» примером, а содержит в себе оболочку, которую я создал, чтобы использовать её в составе игры. Названия сей продукт не имеет, но он создавался мною с учетом того, что возможно игре потребуется портирование на другую платформу. В настоящий момент в полностью работоспособном состоянии имеется только Windows-версия, однако все обращения к операционной системе выполнены через виртуальные функции, которые можно заменить. На практике мне это понадобилось, когда пришло время выкладывать игровой сервер на просторы Интернета. Для бесплатного тестирования мне был доступен только вариант сервера, которым управляла Unix-подобная ОС. В результате пришлось дописать в оболочку ветку для Unix-а. Сам сервер при этом я не менял вообще, только заменил функции, которые требовались серверу для взаимодействия с ОС. Я не особенно разбираюсь в API Unix-а, но у меня есть хороший знакомый программист (на данном ресурсе носящий кодовое имя Komei), который прекрасно понимает в этой теме. С его помощью портирование сервера было выполнено в течение нескольких дней. Кроме того мой приятель любезно предоставил мне свой Unix-сервер для запуска и тестирования моего игрового Интернет-сервера, а это, как минимум, была приличная материальная помощь моему проекту, так как держать собственный выделенный сервер не такое уж и дешевое удовольствие.

Пример в составе статьи мне понадобился по причине того, что вся вторая часть статьи посвящена устройству сетевой игры. И там я вынужден периодически переходить в объяснениях на код. А сами по себе отдельные куски кода представляются мне достаточно бессмысленными. Обнародовать исходный код игры я посчитал «перебором», всё же это для меня коммерческий продукт, кроме того, относительно небольшой пример представляется мне гораздо более понятным. Поэтому я решил обнародовать исходный код оболочки снабдив её для понятности собственным примером. Пример показывает работу с моим GUI, проигрывает звук и демонстрирует сетевое взаимодействие. Т.е. пользователь может нажать кнопку «Включить сеть», потом кнопку «Создать» и будет создан игровой сеанс, к которому можно присоединиться из примера, запущенного на другом компьютере. Конфигурация игрока в сеансе сводится к возможности менять его цвет, но для демонстрации принципа этого вполне достаточно.

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

Я не возражаю против того, чтобы кто-то воспользовался моей работой в своих корыстных или бескорыстных целях. Но я не обещаю, что буду развивать этот проект или что я буду поддерживать совместимость с текущей версией. Также я уточняю, что GUI проектировался не для скорости, а для универсальности, что и требовалось в моем случае. Кроме того, у меня реализована ветка, которая работает только с 16-битным цветом, который нужен моей игре, однако никто не мешает дописать ветку кода, которая будет работать с 32-битными цветами, а также использовать аппаратное ускорение GPU там, где это возможно. Скачать этот проект можно здесь.

Проект примера выполнен на Visual Studio 2008 и использует язык C++ и DirectX9.

Проблемы, которые мне предстояло решить

Для начала вот список проблем, которые существовали в игре год назад:

  • Игра полноценно работала только под Windows XP. Под Windows 7 игра нормально запускалась, но локальная сеть не функционировала вообще. А Windows 8/10 просто отказывались запускать игру.
  • Игра была намертво «прибита гвоздями» к платформе Windows. Активно использовался DirectX, а также MFC.
  • Оформление меню было очень «стрёмным» даже на мой взгляд. Связано это было с тем, что я занимаюсь программированием и к рисованию имею весьма далекое отношение, а доделывал игру я уже в одиночку.
  • Визуально с «иконками исследований» дело обстояло еще хуже, чем с меню.
  • Голосовая озвучка оставляла, мягко говоря, желать лучшего.
  • Не было интернет-сервера, который мне всегда хотелось иметь, так как я считаю, что самое интересное в RTS — это игра по сети.

Постепенно шаг за шагом я решал эти проблемы и сейчас чувствую, что наконец-то результат меня вполне устраивает. Однако так было не всегда…

С чего начать

Проект игры когда-то делался в Visual Studio C++ 6.0. Теперь же этот Visual Studio C++ 6.0 даже не пожелал работать в Windows 8. Поэтому первым делом, мне нужно было перенести проект в новую для него среду. С одной стороны всё должно быть просто, так как Visual Studio умеет конвертировать проекты из прошлых версий в собственный формат, однако не тут-то было. Дело в том, что я использовал ассемблер tasm32, который создавал мне obj-файл. Этот файл без проблем линковался с Visual Studio C++ 6.0, но совсем не хотел линковаться с Visual Studio 2008. Новой версии tasm32 на свете не оказалось, а другие ассемблеры типа masm почему-то пылали к синтаксису tasm32 лютой ненавистью и выдавали множество ошибок при попытке подсунуть им мой asm-файл.
Поразмыслив, я решил, что не готов на данной стадии переписывать код ассемблера под другой синтаксис, так как по началу я вообще не был уверен, что я двигаюсь в правильном направлении. В результате я принял следующее решение: установил Windows XP, поставил в неё Visual Studio C++ 6.0, создал проект типа DLL, написал экспортируемые функции, которые просто вызывали через себя функции ассемблера и прилинковал к нему свой ассемблерный obj-файл, который в Windows XP прекрасно создавался через tasm32.exe. В результате я получил библиотеку asm.dll, которую я уже смог без проблем подключить к новому проекту в Visual Studio 2008. Такое решение заработало, практически, сразу и пока я решил остановится на нем. Я, естественно, понимаю, что это решение абсолютно не переносимо на другие платформы, но если уж действительно встанет вопрос о портировании, то можно «собрать волю в кулак » и перевести ассемблер в другой синтаксис. Пока же судьба этой игры для меня туманна, хотя определенно я её за последний год в прямом смысле реанимировал. В любом случае, я потратил на этот проект уже достаточно сил, чтобы наконец-то получить от вселенной четкий ответ по данному поводу.

После того, как я смог запустить игру в Windows 8 с помощью Visual Studio 2008 у меня в руках на-конец -то появился отладчик. Для начала мне нужно было разобраться в причинах, по которым новые версии Windows не желали запускать игру. Я давно не занимался играми и не особенно следил за той эволюцией ограничений, которую Windows стала накладывать на работу старых программ. Лично для меня эта причина оказалась очень неожиданной. Я обнаружил, что из Windows 8/10 удален 16-битный цветовой режим, который использовался моей игрой. После нескольких «магических ритуалов» мне всё же удалось запустить игру и даже немного поиграть, но работало всё угрожающе медленно, а вместо ландшафта на основной части экрана был чистый и прекрасный черный цвет. Для себя я отметил, что 16-битный режим теперь эмулируется и имеет ограничения по использованию.

Битва за 16-битный видеорежим

Как бороться с этой проблемой?
Как минимум имеется 2 выхода:

  1. Переделать всю графику в игре так, чтобы она могла работать в 24-битном разрешении. В моем случае этот вариант плох тем, что вся графика хранилась в сжатом виде и рисовалась ассемблером. Все функции ассемблера умели работать только с 16-битной графикой, самих функций было много и работали они достаточно быстро, предполагали много нюансов при рисовании и, главное, за эти годы я порядком забыл как всё это функционирует в деталях.
  2. Создать в ОЗУ собственную поверхность, которая для игры будет являться экранной памятью. Рисовать всё на этой поверхности, а потом копировать её на реальный экран. Этот вариант я и выбрал в качестве единственно верного. При таком решении также появлялся жирнейший плюс — мне теперь почти не нужен DirectX для работы с графикой, так как единственное что он теперь должен был делать — это копировать мою поверхность на экран. Но есть в таком решении и не сразу очевидный минус — DirectX позволял мне запросто выводить текст, и я, естественно, так и делал. А теперь получалось: «нет DirectX — нет текста».

В результате я решил сделать следующее — написать что-то типа «оболочки», через которую игра общалась бы с операционной системой. Любые обращения к ресурсам ОС выполнять через виртуальные функции, чтобы в случае чего их можно было заменить. Практически, это даёт возможность относительно быстро портировать игру на другую ОС, но как всегда не всё так просто… почти любой программе, которая хоть как-то общается с пользователем нужен интерфейс. Интерфейс же состоит из разных маленьких (но милых) элементиков типа "Текстовое поле", "Списочек", "Дерево", "Диалоговое окошечко" и прочее. Иными словами, если идти таким путём, то нужно делать собственный GUI, который будет работать одинаково в условиях любой ОС. Кроме этого нужно как-то решить проблему с проигрыванием звуков и сетевым взаимодействием.

Для звуков я решил использовать библиотеку Squall. Плюсов её использования было несколько… во-первых, эта библиотека уже использовалась в старой версии игры, во-вторых, по сравнению с другими бесплатными аналогичными решениями она играла почти все wav-файлы, которые я ей подсовывал, в-третьих, я был лично знаком с автором cesium, что тоже немаловажно. Главный минус Squall — это то, что она уже давно не развивается и существует только под Windows. Однако в последнее время я стал стараться делать такую структуру у своих программ, чтобы можно было относительно без труда заменить внешнюю библиотеку каким-нибудь аналогом. В результате я сделал всё взаимодействие со Squall через небольшую прослойку из собственного класса. Чуть позже я в качестве эксперимента заменил Squall на BASS, однако не особенно ощутив пользу от этой замены, вернул Squall обратно. Зато теперь я знал, что если понадобиться другая платформа, то я смогу использовать BASS, который есть везде.

Так как мне предстояло, практически, избавиться от DirectX в составе игры, то для начала я просто закомментировал все h-файлы DirectX-а. Нажал на Build и приготовился оценить объем предполагаемой работы по замене функций DirectX на свои. Когда длинный «список проблем» наконец остановился, то внизу очень красноречиво отобразилось количество найденных ошибок в общей сложности чуть более 9 тысяч. Меньше всего ошибки касались DirectInput-а, так как ввод данных с мыши и клавиатуры — это относительно маленькая часть кода, а вот зато DirectDraw и DirectPlay отметились, как говорится, «по полной».

Существовала и еще одна мощная проблема. Дело в том, что к игре прилагается редактор карт и кампаний. А он был полностью написан с использованием MFC, а так как это, практически, оконное приложение Windows, то я сразу принял решение даже не думать о том, чтобы редактор в перспективе мог работать на другой платформе. Но дело в том, что и игра, и редактор — это, в моем случае, был один и тот же проект (в прямом смысле это был один и тот же exe-файл, который запускался в разными параметрами). Соответственно, мне нужно было разделить этот проект на 2 части: игра должна была работать почти полностью независимо от Windows, а редактор, напротив, оставался MFC-проектом. В результате когда я выдирал игру из фреймворка MFC я частенько натыкался на ситуацию, когда общие для этих проектов функции должны теперь выполнять свою задачу по-разному. Приходилось вставлять в код условную компиляцию и порождать вторую ветку кода.

Первым делом я стал создавать собственную оболочку, которая помогла бы мне освободиться от DirectX-зависимости. Мне было необходимо, чтобы оболочка умела работать с 16-битным разрешением, имела бы в своем составе стандартные компоненты для формирования интерфейса, а также позволяла бы выводить текст.

Структура «оболочки»

Базовым классом всей оболочки я сделал класс GGame, который уже будет содержать в себе все остальные важные объекты. Класс GPlatform отвечает у меня за общение с ОС, исключая графические операции. Зато всё что касается работы с файловой системой, обработкой цикла сообщений, завершением работы приложения, запуском потоков и процессов относится к GPlatform. Класс GSound отвечает за звук, а класс GNet является либо сервером GNetServer, либо клиентом GNetClient, в зависимости от задачи. Самым сложным был класс GDevice, который заведует графикой.

Класс GDevice работает с поверхностями GSurface. Эти поверхности определены мною весьма абстрактно, т.е. там нет никаких намеков на количество битов на пиксель, зато определено множество абстрактных функций, которые должны были заменить мне стандартный GDI. Однако в реальности в игре никогда не создаются объекты типа GSurface, вместо этого используются дочерние объекты типа GSurface16, которые как раз умеют выполнять работу GDI применительно к 16-битным поверхностям. Я рассудил так, что если мне потребуется дать своей оболочке возможность работать с 24-битной графикой, то я всегда смогу создать аналогичный класс GSurface24 и это не потребует каких-то глобальных изменений общей структуры оболочки.

Класс GDevice также отвечает за работу с разрешениями экрана, он имеет абстрактные функции, которые определяют доступные разрешения экрана, позволяет выбрать новое разрешение. GDevice также умеет масштабировать размер сцены под размер монитора. Например, игра использует любое разрешение просто за счёт увеличения видимой области игрового поля, а вот меню всегда работает только в разрешении 1920 x 1080. Однако если реальное разрешение монитора меньше, то GDevice выполняет масштабирование меню под текущее разрешение монитора. Возможно, что более хорошим решением было бы сделать несколько видов меню под разные разрешения монитора, но я всё же не корпорация и подобные решения в моем случае, будут просто нерациональны. В любом случае, на ноутбуке с разрешением 1366 x 768 меню выглядит вполне приемлемо.

Все эти базовые классы GPlatform, GDevice, GNet и т.д. содержат множество абстрактных функций. В реальной программе вместо класса GPlatform используется дочерний класс GPlatformWindows, вместо класса GDevice — класс GDeviceOnimodLandDX9, вместо класса GNet — либо класс GNetServerLibUV, либо класс GNetClientLibUV, т.е. я использую объекты классов, которые уже приспособлены под нужную платформу и мою игру. Сама «оболочка», не имеет ни об игре, ни об ОС, ни малейшего понятия.

Да и самый основной класс на самом деле превращается из GGame в класс GGameOnimodLandDX9. Как же всё это связывается вместе? Ответ заключается в том, чтобы никогда не создавать в программе важные объекты напрямую через оператор new. Вместо этого существуют виртуальные функции, которые осуществляют создание объекта нужного типа. Например, абстрактные функции класса GGame:

// Создание платформы
virtual GPlatform* GGame::NewPlatform(GGame* owner, const char* game_name)=0;

// Создание звука
virtual GSound* GGame::NewSound()=0;

выглядят для класса GGameOnimodLand примерно так:

// Создание платформы
GPlatform* GGameOnimodLand::NewPlatform(GGame* owner, const char* game_name)
{
	return new GPlatformWindows(owner, game_name);
}

// Создание звука
GSound* GGameOnimodLand::NewSound()
{
	#ifdef SQUALL_SOUND
	return new GSoundSquall();
	#endif

	#ifdef BASS_SOUND
	return new GSoundBass();
	#endif
}

А теперь как всё это создается через базовый класс:

// Создание всего, что есть в игре
bool GGame::CreateGame(void* init, int view_width, int view_height)
{
	platform=NewPlatform(this, game_name.c_str());
	platform->Create();

	device=NewDevice(init);
	device->Create(view_width, view_height);
	sound=NewSound();
....
....
....

}

Таким образом, получается так, что базовый класс создает нужные ему объекты, не вникая в тонкости специфики ОС или графического API. Такой подход позволяет отделить основной код программы от особенностей среды выполнения. Также это позволяет относительно легко произвести портирование на другую платформу, а DirectX заменить на OpenGL, так как вся специфика среды выполнения вынесена в небольшие и полностью отделенные классы и их функции.

Немного о моем GUI

Для GUI мне пришлось создать все необходимые компоненты, которые использовала моя игра:

1) Текстовое поле / GStatic — позволяет отобразить любой текст. Дополнительно у текста есть некоторые свойства — можно в любом месте изменять цвет и шрифт текста. По началу, я не был уверен, что эта возможность мне понадобиться, но, оказалось, что она более чем полезна.
Повесть о создании классической RTS в домашних условиях с нуля (часть 2: «Воскрешение») - 4

2) Поле ввода / GEdit — однострочный редактор текста. Я старался, чтобы он по своим возможно-стям был похож на аналогичный компонент Windows. Под конец даже добавил работу с буфером обмена. Вынудило меня к этому исключительно необходимость вводить лицензионный ключ.
Повесть о создании классической RTS в домашних условиях с нуля (часть 2: «Воскрешение») - 5

3) Кнопка / GButton — обычная кнопка, которая реагирует на наведение на неё мышью и нажатие.
Повесть о создании классической RTS в домашних условиях с нуля (часть 2: «Воскрешение») - 6

4) Графическая кнопка / GButtonImage — обычная кнопка, только её внешний вид определяется четырьмя графическими изображениями (обычное состояние, под мышью, нажата и неактивна).
Повесть о создании классической RTS в домашних условиях с нуля (часть 2: «Воскрешение») - 7

5) Поле-флаг / GCheckBox — поле, которое позволяет отмечать себя галочкой по принципу ДА/НЕТ. Справа от поля указывается описание.
Повесть о создании классической RTS в домашних условиях с нуля (часть 2: «Воскрешение») - 8

6) Список / GListBox — это просто список из текстовых строк. В списке можно выбрать любую строку.
Повесть о создании классической RTS в домашних условиях с нуля (часть 2: «Воскрешение») - 9

7) Выпадающий список / GComboBox — компонент выглядит как поле ввода, у которого имеет дополнительная кнопка, открывающая список. Редактирование можно запретить, тогда значение поля можно будет выбирать только с помощью выпадающего списка. Вообще говоря, это достаточно «стрёмный» компонент для реализации, так как содержит в себе сразу три компонента: Поле ввода, Список и Кнопка.
Повесть о создании классической RTS в домашних условиях с нуля (часть 2: «Воскрешение») - 10

8) Конфигуратор / GDiscretBox — компонент используется для настройки уровня, например, гром-кости звука или скорости движения мыши.
Повесть о создании классической RTS в домашних условиях с нуля (часть 2: «Воскрешение») - 11

9) Картинка / GPicture — компонент позволяет загрузить любое количество графических изображений одинакового размера и выбирать из них любое для отображения.
Повесть о создании классической RTS в домашних условиях с нуля (часть 2: «Воскрешение») - 12 Повесть о создании классической RTS в домашних условиях с нуля (часть 2: «Воскрешение») - 13 Повесть о создании классической RTS в домашних условиях с нуля (часть 2: «Воскрешение») - 14

10) Дерево / GTree — сложный компонент, который состоит из узлов, внутри которых могут быть другие узлы. В общем, сложность в том, что можно делать целую иерархию, которую еще и нужно отображать графически. Также есть возможность использовать иконки.
Повесть о создании классической RTS в домашних условиях с нуля (часть 2: «Воскрешение») - 15

11) ) Всплывающее меню / GMenu — аналог меню, которое обычно появляется в Windows при нажатии правой кнопки мыши. Основная сложность заключается в возможности строить многоуровневые меню.
Повесть о создании классической RTS в домашних условиях с нуля (часть 2: «Воскрешение») - 16

12) Диалоговое окно / GDialog — окно с заголовком или без заголовка для расстановки внутри него других компонентов.

13) Сообщение / GMessageBox — частный случай диалогового окна, которое конструируется автоматически по заголовку, тексту и наличию кнопок типа OK и Cancel.
Повесть о создании классической RTS в домашних условиях с нуля (часть 2: «Воскрешение») - 17

14) Сцена / GScene — частный случай диалогового окна, который обычно закрывает собой всю область монитора. У сцены можно установить фоновую картинку, а поверх расставлять другие компоненты. Компонент сцены присутствует всегда. Практически, смена экранов меню является сменой разных сцен.
Повесть о создании классической RTS в домашних условиях с нуля (часть 2: «Воскрешение») - 18

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

Еще некоторые важные моменты в реализации GUI

  • Чтобы не сильно привязываться к особенностям ОС, я создаю только одно главное окно. Потом я ловлю движение мыши по этому окну и определяю свой компонент, в который попадает мышь.
  • В отличии от стандартной логики рисования в играх, когда вся сцена перерисовывается полностью, я стараюсь перерисовывать только те компоненты, которые должны быть перерисованы. Например, если мышь наехала на кнопку, то нужно перерисовать только кнопку.
  • Я не стал реализовывать «честную» модальность, посчитав, что это усложнит портирование, если оно всё же понадобится. Что такое модальность? Это когда какой-то компонент захватывает мышь на себя и никакие другие компоненты на эту мышь не реагируют. У меня всё это тоже есть, но по уму внутри модального компонента должен быть собственный цикл обработки, который и держит управление, пока компонент не будет закрыт.
    Пожалуй, поясню примером. В MFC я могу вывести MessageBox примерно так:

    if (MessageBox("Файл C:Temptest.avi существует. Хотите перезаписать его ?", "Внимание", MB_OKCANCEL | MB_ICONQUESTION)==IDOK)

    В результате появится такой MessageBox:
    Повесть о создании классической RTS в домашних условиях с нуля (часть 2: «Воскрешение») - 19
    Но важно то, что пока я не нажму на кнопку "OK" или "Отмена" функция MessageBox(), которую я вызвал, не будет завершена. И это очень удобно. В моем же GUI сообщение тоже появится, мышь и клавиатура также будут реагировать только на этот MessageBox, но программа не будет ожидать, пока я его закрою — она будет использовать свой основной цикл обработки. Поэтому придется отдельно писать реакцию на нажатие кнопок "OK" или "Отмена" для каждого MessageBox-а. Это, на мой взгляд, неприятный минус моего GUI, но, по счастью, в играх они не так уж часто встречаются.

  • Внутри себя GUI не пользуется понятием «сообщение» как это сделано в Windows.
  • Текущая версия GUI не имеет возможностей для размещения обработчиков в родительском компоненте. Например, в Windows можно расположить на диалоговом окне кнопку и реакцию на нажатие кнопки осуществлять в классе диалогового окна. В моем случае придется писать реакцию OnLButtonClick() именно для кнопки.
  • Для оформление компонентов GUI реализовано понятие "Тема". Работает "Тема" по аналогии с темами в Windows, т.е. позволяет полностью поменять внешний вид компонентов. Моя первая тема была «слизана» с Windows, и она так и называлась GThemeWindows.
    Меню было вполне функционально, но выглядело оно примерно так:
    Повесть о создании классической RTS в домашних условиях с нуля (часть 2: «Воскрешение») - 20
    Для игры я создал тему GThemeOnimod, которая выглядит куда более подходяще для моего случая. Здесь я уже стал использовать прозрачность на бордюре и прочие симпатичные штучки. Важно то, что замена темы ведет к полной замене внешнего вида всех компонентов.

О перерисовывании игрового меню

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

Долго не получалось принять решение по поводу того, что же всё-таки должно быть изображено на фоне меню. Так как игра предполагала противостояние двух рас, одна из которых летает на космических кораблях, а вторая до сих пор постреливает из лука, то не было полной ясности, как связать эти две концепции на фоновых рисунках. После того, как пара картинок была забракована, я вспомнил, что когда-то обсуждалась идея с «фресками». В результате «фрески» превратились в «наскальные рисунки», которые было относительно несложно нарисовать. От высоких технологий меню получило сканер, который периодически сканирует «наскальные рисунки» и обнаруживает новые. Чтобы добавить динамики, я вставил 3 декоративных компонента на правую сторону меню, которые постоянно ведут какую-то бурную деятельность, смысл которой и мне не очень ясен. Вообще идея была такой, что космонавт смотрит на вход в храм через скафандр, но, по-моему, этот визуальный эффект получить не удалось.
Повесть о создании классической RTS в домашних условиях с нуля (часть 2: «Воскрешение») - 21

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

После того, как проблема с меню была решена, я озадачился внешним видом иконок для апгрейдов игре. Я понимал, что в таком виде это оставлять нельзя так как это — «безобразие» уж очень сильно бросается в глаза.

Чтобы было понятно, почему внешний вид существующих иконок действовал на меня очень удручающе, я привожу их внешний вид.
Повесть о создании классической RTS в домашних условиях с нуля (часть 2: «Воскрешение») - 22

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

После перерисовки новый вариант этих же иконок выглядел так:
Повесть о создании классической RTS в домашних условиях с нуля (часть 2: «Воскрешение») - 23

Этот вариант мне кажется вполне пригодным для применения. К слову говоря, таких иконок в игре достаточно много — примерно 80 штук. К счастью, многие из них были похожи по внешнему виду, например, 1-ый уровень брони и 2-ой уровень брони отличаются лишь некоторыми деталями и цветом фона.

Шрифты

Использовать шрифты Windows-а для меня стало теперь невозможным делом. Значит нужно было опять что-то нахимичить. Самый очевидный вариант — это заранее сгенерировать шрифт в виде картинки и отдельно в текстовый файл сохранить координаты каждого символа с этой картинки. Тогда, зная эти координаты, можно будет рисовать символы по одному.
По счастью, несколько лет назад я написал небольшую утилиту, которая как раз этим и занима-лась. Утилиту можно скачать здесь.

Повесть о создании классической RTS в домашних условиях с нуля (часть 2: «Воскрешение») - 24
(Эмоция: Ух, только что заметил на скриншоте слева-снизу… оказывается я это делал, аж, в 2006 году. А мне казалось что не так много времени прошло.)

Думаю, что как пользоваться утилитой примерно понятно. Выбираем шрифт и его характеристики, а также длину текстуры. Лучше в поле Кернинг вписать 1, так как иначе символы могут начать соприкасаться, что совсем нехорошо. Утилита сохраняет текстуру в виде TGA-файла, причем сам рисунок находится на альфа-канале, поэтому программы, которые не показывают альфа-канал выдадут вам чистый белый лист. И тем не менее, на альфа-канале изображение будет присутствовать.

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

Повесть о создании классической RTS в домашних условиях с нуля (часть 2: «Воскрешение») - 25

Я использовал формат TGA, так как его можно запросто без всяких сторонних API сохранить самостоятельно.

Реанимация игры

Когда мой GUI с темой в стиле Windows более менее «начал дышать», у меня наконец-то появилась возможность начать осуществлять реанимацию игры под Windows 8/10. Но на начальном этапе у меня не было собственной сети, что не давало мне начать переделывать саму игру. Поэтому я начал осуществлять свой план с редактора карт. Ему не требовались ни сеть, ни мой пока что вялоработающий GUI, он также не использовал DirectInput, а все данные от мыши и клавиатуры принимал через стандартные сообщения Windows. Но зато он активно использовал ассемблер для рисования игровых объектов.

И было логично начать с малого, потому что я понимал, что стоит мне начать всё крушить, как потом там «концов не найдешь». В общем я опять удалил h-файлы DirectX из проекта, но теперь уже мне предстояло не просто оценить масштабы работы, но и проделать эту работу.

Первым делом я заменил все указатели на поверхности IDirectDrawSurface указателями на собственные поверхности GSurfaceOL. В класс GSurfaceOL добавил функции, которые должны были эмулировать работу поверхностей DirectDraw, только моим способом:

long BltFast(unsigned int dwX, unsigned int dwY, GSurfaceOL* lpDDSrcSurface, RECT* lpSrcRect, unsigned int dwFlags);

Такой подход сразу резко уменьшил количество ошибок выдаваемых компилятором, так как и мои поверхности, и мои функции к ним теперь существовали.

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

В общем, как мне сейчас кажется, я потратил примерно неделю, прежде чем мне удалось выскрести из редактора все упоминания о DirectX. Далее еще какое-то время ушло на то, чтобы заработали мои функции и, наконец, я получил первый важный результат.

Ясное дело, что всё было не так просто, как я тут описал в двух словах, приходилось частенько упираться в какие-то мелкие сложности, которые очень замедляют движение вперед.
Например, это скриншот с уже работающего редактора, но на нем есть дефект, который я отметил красным подчеркиванием.
Повесть о создании классической RTS в домашних условиях с нуля (часть 2: «Воскрешение») - 26
На индикаторе жизни, который состоит их сегментов рисуемых ассемблером, видно, что изображение сильно смазано. Я долго тупил, пока не понял, что дело тут в финальной стадии, которую контролировал DirectX.

Как я уже говорил, я всё изображение рисовал на 16-битной собственной поверхности в ОЗУ. Так-же я создавал уже в видеопамяти через DirectX текстуру формата A1R5G5B5 размером во весь экран (сначала я использовал формат X1R5G5B5, но, к счастью, вовремя заметил, что некоторые компьютеры такой формат не понимают) Далее я выполняю операцию Lock() для текстуры DirectX-а, и копирую в неё свою текстуру из ОЗУ через функцию memcpy(). Освобождаю текстуру DirectX-а через Unlock() и, собственно, я получаю своё изображение на стандартной текстуре DirectX-а. И теперь её можно нарисовать в виде двух текстурированных треугольников. Правда, размер этой текстуры 16 бит, а разрешение экрана 24 бита. Но тут DirectX9 оказался на высоте — он без проблем выполняет конвертацию цветов в момент рендеринга треугольников. Должен признать, я, по началу, пробовал провести эту конвертацию самостоятельно, чтобы загнать данные из ОЗУ напрямую во вторичный видеобуфер, но результат по сравнению с DirectX меня не порадовал.

Итак, я начал это всё объяснять по причине смазанности изображения. Так вот… я сначала упорно искал проблему в этой цепочке, которую я описал чуть выше. А оказалось, что у меня неправильно настроена функция DirectXPresent(), которая выполняет финальный перенос изображения из вторичного буфера в первичный.

У меня эта функция выглядела так:

device_3d->Present(NULL, NULL, NULL, NULL);

Но для оконного режима нельзя указывать NULL в первых двух параметрах, здесь должны быть указаны прямоугольники «откуда» и «куда» выполняется копирование. Я же поторопился и скопировал функцию Present() из полноэкранного решения, где было вполне нормально использовать NULL.

И такие мелочи попадаются постоянно, поэтому оценка труда программиста вообще не поддается четким временным рамкам — любая мелочь может задержать разработку на неопределнный срок. Лично я всегда следовал правилу, что «кажущийся срок нужно умножать на 3». Главная задача разработчика — это не «поскорее исправить ошибку», а «постараться при исправлении одной ошибки не создать новую». Провозившись с игрой примерно год, я даже почти не трогал руками саму игру (за исключением сетевого взаимодействия и меню), а лишь решал косвенные задачи по созданию универсальной «оболочки».

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

Но… думаю, что я достаточно уже рассказал про GUI и сейчас мне надо либо превращать статью в документацию по работе с моей оболочкой, либо пока оставить эту тему до того момента, когда это может кому-нибудь понадобиться.

Зато я решил подробнее остановиться на сети. В моей прошлой статье я описал Сеть очень коротко и заострил внимание не столько на создании сетевого взаимодействия, сколько на особенностях сетевой игры применительно к RTS. Я также обратил внимание, что некоторые читатели моей прошлой статьи высказали сожаление по поводу малого объема информации на эту тему с моей стороны. Поэтому в этот раз я решил пояснить те принципы, которые я использовал для написания Интернет-сервера и сетевого клиента. На мой взгляд, материал здесь куда более «суровый» по отношению человеку, который решил «вникнуть в суть».

Сеть

Я не думаю, что без анализа прилагаемого примера можно будет в тонкостях разобраться в том, о чем я буду пытаться говорить. Однако общие мысли на этот счёт, я надеюсь, мне удастся донести в любом случае. Также я должен отдельно отметить, что подобные решения я придумываю самостоятельно, поэтому всегда есть риск, что какое-то моё решение проблемы окажется неоптимальным. Библиотеку для сети libuv я изучал в основном методом «научного тыка», и я также вполне допускаю, что люди, которые занимаются сетевым программированием постоянно, смогут поправить какие-то мои трактовки. А теперь, пожалуй, я вернусь к теме статьи…

Сначала я честно хотел использовать для сетевого взаимодействия библиотеку WinSock. Однако быстро передумал, так как было очевидно, что таким образом я опять окажусь полностью привязанным к Windows, чего мне очень не хотелось. Поэтому я порылся в интернете и обнаружил интересное решение под названием libuv. Данная библиотека является бесплатной. Работает на Windows, Unix, Mac OS и Android. А также не тянет за собой современные новшества, вроде требования использовать последний стандарт языка C++. Да и вообще она написана на чистом C, что я считаю дополнительным благом для разработчика, так как мой вечноспасительный принцип звучит как: «чем проще — тем лучше».

По началу у меня было 2 проблемы:

  1. Я никогда не писал серверов с нуля, поэтому мне нужно было придумать общую структуру программы.
  2. Документация на libuv, на мой взгляд, оставляет желать лучшего. Меня вообще всегда удивляло, как можно сделать вполне приличный продукт и полениться более-менее описать его возможности. Но, к сожалению, этим страдают почти все разработчики. Последняя стадия лени в этой области — это использование автоматической генерации документации с помощью утилиты Doxygen (и ей подобных), которая превращает в справку комментарии в коде, а также автоматически генерирует связи между классами и структурами. Я, вероятно, отстал от жизни, но я не вижу лучшего способа навредить собственной библиотеке, чем автоматически сгенерировать для неё «помойку» из всевозможных структур и диаграмм, где часто непонятно с чего начать.

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

Несмотря на то, что сейчас разработчики игр часто убирают возможность игры по локальной сети, я решил, что сохраню и этот вариант сетевой игры тоже. В первой части статьи я собственноручно объяснял, что для игры в RTS по локальной сети никакой сервер вообще не нужен, так как игра работает на каждом компьютере независимо и никакого координирующего центра не требуется. И это действительно так. Однако в моем случае я хотел получить два варианта сетевой игры: через интернет и через локальную сеть. Если бы я стал использовать для локальной сети одноранговый способ взаимодействия компьютеров, то такое решение создало бы вторую ветку кода. Работало бы такое решение быстрее? Вероятно да, но навряд-ли игрок смог бы ощутить разницу, так как в локалке данные обычно быстро доходят до цели.

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

Немного теории про адреса и порты

Как, наверное, многие знают, каждому компьютеру в мире, включенному в Интернет, должен быть присвоен уникальный адрес (если, конечно, не используется NAT или что-то подобное). Этот адрес называется IP. IP-адрес состоит из 4-х цифр и в «человеческом виде» выглядит примерно так: 234.123.34.18

По таким адресам компьютеры и находят друг друга. Однако обычно на одном компьютере запущено одновременно множество программ, поэтому существует дополнительное понятие «порт». Программы могут открывать для себя эти порты и устанавливать через них взаимодействие. Чтобы было более понятно… IP-адрес — это как бы: Россия, Бухаловская обл., село «Большое голодухино», ул. «Новый русский спуск», д.18, а «порт» — это номер квартиры, где деньги лежат в которой проживает конкретный человек. Без номера квартиры(порта) невозможно доставить «письмо» конкретному человеку (программе), поэтому понятие «порт» имеет очень важное значение. Обычно порт записывается после IP-адреса через двоеточие, например: 234.123.34.18:57

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

Кратко поясню разницу для тех, кто не очень знаком с этой темой.
UDP позволяет передавать данные порциями. Такая порция данных называется датаграмма. Датаграмма может быть отправлена на указанный IP-адрес и порт. Но… протокол UDP «ничего вам не обещает», т.е. отправленная датаграмма может запросто потеряться по дороге и в этом случае получатель просто ничего не получит. А если было отправлено несколько датаграмм, то они могут прийти в другом порядке или некоторые могу не прийти, т.е. тут возможны любые варианты и это является совершенно законным поведением для UDP. Единственное, что гарантирует UDP, так это тот факт, что если датаграмма всё-таки пришла, то она пришла полностью, т.е. не может прийти «половина» датаграммы.

TCP работает совсем по иному принципу. Он не отправляет какие-то непонятные датаграммы в неизведанное сетевое пространство на произвол судьбы — он сначала устанавливает канал связи с получателем и четко шлёт данные именно по этому каналу. TCP гарантирует, что все переданные данные дойдут до получателя, и дойдут именно в том порядке, в котором они были отправлены. Сами же данные приходят не в виде датаграмм фиксированной длины, а в виде потока байт (хорошей аналогией служит побайтная запись данных в файл). Обратите внимание, что при таком подходе TCP может делить отправляемые сообщения на части как ему вздумается, т.е. получатель может сначала получить лишь часть отправленных данных, а через некоторое время подоспеет и оставшаяся половина.

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

Выбор протокола

На начальной стадии мне потребовалось выбрать протокол для взаимодействия. Если не писать собственное решение, то тут имеется всего два варианта: TCP и UDP. UDP хорош там, где нельзя допускать задержек в игровом процессе. Обычно сама игра при этом выполняется на сервере, а клиенты лишь получают от сервера данные об изменениях в игровой ситуации. Такой подход позволяет буквально «не обращать внимания» на игрока, с которым плохая связь. Все остальные игроки будут продолжать играть вполне комфортно, так как сервер не останавливает игру, чтобы дождаться отстающего. Пример такой игры — это Counter Strike.

В случае же с RTS такой подход чаще всего не годится, так как игровой процесс требует большого количества вычислений и поэтому выполняется на каждом компьютере, и эти компьютеры должны всё делать одинаково. Поэтому каждый компьютер от каждого другого компьютера должен постоянно получать список действий, проделанных игроком за один «сетевой такт». Если список запаздывает, то придется подождать, пока он будет получен. Т.е. даже если использовать UDP, то контроль за доставкой сообщений придется выполнять самостоятельно. Поэтому был выбран TCP.

Как я слышал, Starcraft2, несмотря на то, что он относится к жанру RTS, работает-таки на сервере. Возможно, что современное железо дошло до такого уровня, чтобы использовать этот способ. Но в моем случае сеть сделана «классическим» для RTS способом.

Что же делает сервер?

На самом деле он не делает почти ничего, пока не получит какое-либо сообщение от клиента. В случае с TCP задача сервера — открыть порт и слушать его. Если на этот порт приходит запрос от клиента с просьбой об установке соединения CONNECT, то сервер должен выполнить ACCEPT, который открывает новый случайный порт и сообщает его клиенту. Клиент, получив случайный порт от сервера, открывает собственный случайный порт и по этим портам между сервером и клиентом устанавливается соединение. Соединение будет существовать до тех пор, пока одна из сторон не пожелает его закрыть, либо пока не произойдет обрыв связи по техническим причинам. Сервер устанавливает по одному соединению с каждым клиентом. Все данные, которые клиенты пересылают друг другу, проходят через сервер, но никогда не напрямую от клиента к клиенту, как в случае с одноранговой сетью.

Мой сервер занимается следующими задачами:

  • Обменивается «приветствием» с клиентом и, если всё в порядке, то предоставляет клиенту возможность обмениваться сообщениями с собой и другими клиентами.
  • Перебрасывает сообщения между клиентами, выступая в роли посредника.
  • Регистрирует новый игровой сеанс и рассылает информацию о нем тем клиентам, которые запрашивают список существующих сеансов (игровой сеанс создается хостом, т.е. клиентом, который создает сетевую игру и ожидает других игроков для присоединения к ней).
  • Удаляет игровой сеанс, если в нем больше нет игроков. Сервер следит за тем, какие игроки входят в какой сеанс.
  • Занимается пересылкой текстовых сообщений между игроками, т.е. например, ориентируясь на имя игрока, может передать приватное сообщение только данному игроку. Практически, это обычный чат.
  • Отслеживает ситуацию с экстренным отсоединением игрока — сообщает об этом факте другим участникам игрового сеанса.
  • Следит за сетевой активностью каждого игрока. Если от игрока долго не поступает никаких данных, то сервер полагает, что здесь что-то не так и сбрасывает соединение.
  • Отвечает на ping-запросы от клиента. Пингование сервера позволяет приблизительно определить задержки в соединении, а также поддерживать активность клиента, за которой следит сервер.
  • Позволяет осуществлять администраторский контроль над своей работой. Вообще, администрирование — это отдельная история, которая не имеет ничего общего с игрой. В моем случае, я написал для себя отдельную утилиту, которая будет контролировать Интернет-сервер моей игры. Ничем другим эта утилита не занимается, как только показывает статистику сервера: количество игроков, количество игровых сеансов, связь между игроками и сеансами и прочее. Также она позволяет перезапускать и выключать сервер.

Что же делает клиент?

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

  • Обменивается «приветствием» с сервером и в случае успеха получает возможность обмениваться сообщениями по сети.
  • Создает собственный игровой сеанс (в этом случае такой клиент называется хост, так как он является «старшим по сеансу»).
  • Запрашивает у сервера список уже существующих игровых сеансов.
  • Имеет возможность присоединиться к уже существующему игровому сеансу.
  • Будучи участником сеанса, может изменять свои характеристики, например, выбирать политический союз и расу. Кроме этого хост может добавлять в сеанс ботов или удалять из сеанса других игроков, а также отменить сеанс.
  • Обмен текстовыми сообщениями между присутствующими на сервере, говоря не по-русски, «чат».
  • Если кто-то из участников сеанса не имеет той карты, на которой будет происходить игра, то хост должен переслать ему эту карту.
  • Хост имеет право запускать игру, если все игроки отметились флагом «Готов».
  • Во время игры клиенты накапливают действия игрока от мыши и клавиатуры за определенный период времени (сетевой такт) и отсылают их другим игрокам. Невозможно продолжать игру, если не был осуществлен полный обмен этими сообщениями между всеми участниками игрового сеанса. Если какой-то игрок запаздывает, то остальные игроки будут его ждать. Если ожидание затягивается, то выводится сообщение на тему «Проблема связи с таким-то игроком» и счетчик времени, который отсчитывает время в обратную сторону до 0. Если за это время связь с игроком не восстанавливается, то тот принудительно отсоединяется от игры.
  • В случае, если замечена «рассинхронизация сети» (когда на разных компьютерах игра начинает протекать по-разному), то хост пытается произвести восстановление испорченной игры. Для этого, практически, делается запись всех данных игры в файл и отправка этого файла остальным участникам сеанса. В свою очередь другие участники читают этот файл и полностью заменяют свои данные на данные, полученные от хоста. По сути, это аналог Save/Load.

Различия между интернет-сервером и сервером локальной сети

Как это не парадоксально звучит, но сервер локальной сети устроен несколько сложнее, чем Интернет-сервер. Почему так?

Обратите внимание, что Интернет-сервер работает в качестве отдельной программы, которая один раз где-то там запущена и должна в идеале работать вечно. Локальный же сервер создается на время на компьютере, который выполняет роль хоста. Такой сервер существует не в виде отдельной программы, а лишь в виде отдельного потока, который имеет доступ к любым данным основного приложения, что влечет за собой мелкопакостные проблемы, которые лечатся синхронизацией потоков. А «многопоточность» — это вообще отдельный раздел отладки, который может сильно и надолго подпортить жизнь любому разработчику.

Далее, заметьте, что принцип подключения клиента к Интернет-серверу и локальному серверу абсолютно разный. Чтобы клиент мог подключиться к Интернет-серверу он должен знать его IP-адрес и порт. И эти данные должен указывать игрок в каком-нибудь текстовом поле. А в локальной сети существующие игровые сеансы должны обнаруживаться без указания каких-либо IP-адресов. Достигается такое действие через так называемый «широковещательный запрос», у которого IP равен 255.255.255.255. Этот адрес обозначает «всю локальную сеть сразу», но для протокола TCP это не сработает, так как это фича UDP в чистом виде. Почему TCP не сможет работать с широковещательным адресом? Ну, как я выше объяснял, TCP обязательно должен установить канал связи и общаться строго один на один. А здесь же нужно «окликнуть» всю локальную сеть по принципу «эй, есть тут кто? отзовитесь!». Ну и те, кто «есть», должны отозваться, передав просителю информацию об игровом сеансе. Поэтому на локальном сервере придется использовать еще и UDP.

Ну, и на сладкое… представьте, что во время игры хост, на котором работает локальный сервер, вдруг проиграл или просто вышел из игры по каким-то причинам. Что у вас произойдет? А случится то же самое, что происходит в игре Diablo2, когда тот, кто создал игру, решил покинуть игровой сеанс раньше остальных. В Diablo2 у остальных несчастных на черном экране вылезает сообщение в стиле «хост больше недоступен», и, собственно, всё… игроки просто выбрасываются из игры. Причина такого поведения в том, что уходя из сеанса хост закрывает и свой локальный сервер, а ведь все игроки присоединены именно к нему. Для борьбы с таким безобразным поведением в DirectPlay когда-то существовала прекрасная штука, которая называется «миграция хоста». В двух словах это выглядит так… когда от локального сервера приходит информация о его внезапной кончине, оставшиеся клиенты принимают решение запустить новый сервер и переприсоединиться к нему заново. Запуск локального сервера можно делать на клиенте, который самый первый в списке игроков сеанса. Далее сервер должен подождать пока все, кто был ранее в игре, смогут к нему подключиться, ну, и, если всё в порядке, то игра продолжится.

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

Но даже допустим, что сервер вдруг взял и «умер», а игроки были отсоединены. После того, как разработчика перестают проклинать за его «кривые руки», игрок обычно пытается снова подключиться к серверу. Но… если сервер «умер», то он не заработает до тех пор, пока его кто-то не перезапустит, а если за работой сервера никто не следит, то это может произойти очень нескоро. В результате гнев игрока может начать стихийно нарастать, что может привести к серьёзным психологическим последствиям. Он начнет плохо спасть, анализы резко ухудшатся и, практически, человек окажется близок к нервному расстройству. Лично я не хотел бы брать на себя такую ответственность, поэтому решил заранее подстраховаться.

Интернет-сервер не должен работать самостоятельно — должен быть контролёр, который будет запускать сервер и стараться следить за его работой. Для этого требуется периодически посылать на сервер сигнал и получать ответ. Если сигнал остался без ответа, то это означает, что сервер «терпит крушение». В этом случае нужно сначала убить процесс сервера окончательно, а потом запустить сервер заново. Такой подход должен хоть как-то защищать сервер, хотя не знаю, насколько это будет эффективно на практике, но я не сомневаюсь, что это ход в правильном направлении.

Кроме того, программа-контролер может выполнять некоторые дополнительные действия. Например, можно поручить ей обновление сервера. В моем случае это выглядит так:
1) Например, я решил что-то поправить на сервере и собрал новую версию сервера, но если игрою кто-то интересуется, то на сервере постоянно кто-то играет. Перезапуск сервера приведет к тому, что игроки будут выброшены из игры, что всегда чревато душевными переживаниями.
2) Зная об этом, я учу сервер и контролирующую утилиту обновлять сервер в «мягком режиме», который выполняется следующим образом. Через утилиту администрирования новый сервер передается в виде обычного файла, который попадает на компьютер, где выполняется основной сервер. После этого основной сервер закрывает свой слушающий порт, но не завершает работу, т.е., практически, новые игроки больше присоединиться не смогут, а те кто «уже тут», будут продолжать спокойно играть. Далее этот сервер будет периодически проверять на ситуацию, когда от него отсоединяться все игроки, и тогда он спокойно завершит работу. В это время программа-контроллер обнаружит присланный файл нового сервера и запустит его на выполнение параллельно со старым сервером. Так как слушающий порт от старого сервера будет уже освобожден, то его теперь начнет слушать новый сервер, который и будет подключать на себя всех новых игроков. Через некоторое время старый сервер сам себя выключит, и в работе останется только одна новая версия сервера.

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

Функции библиотеки libuv

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

Взаимодействие libuv с пользователем целиком построено на использовании функций обратного вызова или callback-функциях. Как это работает? Например, пользователь хочет отправить сообщение по TCP. Для этого используется функция uv_write(), которая, естественно, принимает в качестве параметров «что отправлять» и «через какой сокет». Но кроме этого нужно еще указать и адрес пользовательской функции, которая будет вызвана, когда отправка успешно завершится. Именно эти функции и позволяют контролировать происходящие события. То же самое касается и приема сообщений, только для этого применяется функция uv_read_start(), которой тоже указывается пользовательская функция, которая будет вызвана после приема очередной порции данных.

Самой важной функцией является функция uv_run(), которая, по сути, представляет из себя что-то вроде цикла обработки сообщений в Windows. Все вызовы callback-функций библиотека производит только внутри uv_run(). Это означает, что даже если сама libuv и приняла какое-то сообщение по сети, то пользователь о нем никогда не узнает пока не будет вызвана uv_run().

Функция uv_run() объявлена следующим образом:

int uv_run(uv_loop_t* loop, uv_run_mode mode);

Первый параметр uv_loop_t* loop является указателем на структуру, которую libuv использует для каких-то своих личных надобностей. Эту переменную нужно создать один раз и больше никогда не трогать. Создать её можно, например, так:

uv_loop_t loop;
memset(&loop, 0, sizeof(loop));
uv_loop_init(&loop);

Это всё, что касается параметра loop и нет особой необходимости знать, как он используется внутри libuv. Но… есть один важнейший нюанс. Если у вас многопоточная программа, то для каждого потока нужно использовать собственный loop. В моем случае я создаю один loop для самой игры и второй для локального сервера, который выполняется в другом потоке. И соответственно, у меня на каждом потоке вызывается собственный uv_run().

Второй параметр uv_run_mode mode определяет режим, в котором будет работать функция uv_run(). Для сервера нужно использовать значение UV_RUN_DEFAULT, а для клиента — UV_RUN_NOWAIT. Попробуем разобраться почему именно так.

Параметр UV_RUN_DEFAULT заставляет функцию uv_run() выполняться до тех пор, пока для неё есть хоть какая-то работа. А такой работой, например, является задача по прослушиванию порта. Т.е. если сначала был создан сокет, который слушает порт, то uv_run() никогда не завершится, пока этот сокет будет существовать. А это и есть основная задача сервера — ожидать соединение от клиента и устанавливать его. Поэтому вариант с UV_RUN_DEFAULT является для сервера очень правильным, а строка:

uv_run(&loop, UV_RUN_DEFAULT)

часто бывает последней строчкой в программе, так как при выходе из этого цикла сервер просто завершает работу.
Выход из функции uv_run(&loop, UV_RUN_DEFAULT) произойдет самостоятельно, когда пользователь уничтожит слушающий сокет.
Для аварийного выхода из функции uv_run(&loop, UV_RUN_DEFAULT) предназначена функция uv_stop(), которая принимает в качестве параметра тот же самый loop. После такого вызова uv_run() завершит работу, но вернет ошибку, которая будет означать, что её прервали слишком рано и ей еще есть чем заняться. Никто, кстати, не мешает в этом случае вызвать uv_run() снова.

Параметр UV_RUN_NOWAIT заставляет функцию uv_run() разобраться только с теми событиями, которые произошли к настоящему моменту. Т.е., если были получены какие-то сетевые сообщения, то для них будут вызваны callback-функции. После этого функция uv_run() будет завершена. Такое поведение как раз хорошо подходит для клиента, так как клиент помимо обмена сетевыми сообщениями, занимается еще и самой игрой. В моем случае я вызываю uv_run(&loop, UV_RUN_NOWAIT) один раз в начале игрового такта и один раз в конце (частота тактов примерно 60 Гц). Это сделано, чтобы была возможность обработать перед началом такта полученные сообщения, а после окончания такта сразу отправить собственные.

Как было сказано выше, протокол TCP требует обязательной установки соединения. Запрос на соединение всегда отправляет клиент на конкретный IP-адрес и порт сервера. Для этого испольуется функция uv_tcp_connect().
Функция uv_tcp_connect() объявлена следующим образом:

int uv_tcp_connect(uv_connect_t* req, uv_tcp_t* handle, const sockaddr* addr, uv_connect_cb cb);

Первый параметр uv_connect_t* req — это указатель на какую-то структуру, которая, по-видимому, для чего-то очень нужна libuv. Задача пользователя просто создать эту структуру и передать её в функцию. Создание структуры выполняется более чем просто:

uv_connect_t connect_data;

Я на всякий случай пишу в неё нули, но, кажется, это не обязательно:

memset(&connect_data, 0, sizeof(connect_data));

Также обратите внимание, что эта переменная должна продолжать существовать и после вызова uv_tcp_connect(), так как её адрес используется в callback-функции, поэтому проще сделать её глобальной.

Второй параметр uv_tcp_t* handle — это TCP-сокет, который должен быть создан заранее, но не привязан к каком-либо порту. Создание TCP-сокета выполняется функцией uv_tcp_init(), которая будет рассмотрена немного позже.

Третий параметр const sockaddr* addr — это IP-адрес и порт сервера, у которого запрашивается соединение. У libuv есть функция uv_ip4_addr(), которая помогает заполнить эту структуру данными.

sockaddr_in dest;
uv_ip4_addr("234.123.34.18", 57, &dest);

Четвертый параметр uv_connect_cb cb — это пользовательская функция обратного вызова. И именно в этой функции пользователь сможет определить было ли установлено соединение и как-то отреагировать на этот факт.
В моем случае функция обратного вызова выглядит примерно так:

void OnConnect(uv_connect_t* req, int status)
{
	if (status==0)
	{
		// соединение установлено
		...
		...
		...
	}
	else
	{
		// соединение не состоялось
		...
		...
		...
	}
}

Создание сокетов.

TCP-сокет описывается структурой uv_tcp_t. Для начала нужно выделить память под эту структуру:

uv_tcp_t* tcp_socket=malloc(sizeof(uv_tcp_t));

Желающие могут очистить выделенную память ноликами, хотя это необязательная операция:

memset(tcp_socket, 0, sizeof(uv_tcp_t));

Далее можно создать сам сокет:

uv_tcp_init(&loop, tcp_socket);

здесь loop — это тот же самый многострадальный loop, который подается в uv_run().

А вот теперь очень важный момент для писателей игр:

uv_tcp_nodelay(tcp_socket, true);

Попробую пояснить, что это за настройка. Дело в том, что протокол TCP считает себя очень умным протоколом и он знает, что отправлять данные малыми порциями невыгодно, так как минимальный передаваемый размер данных в любом случае будет равен размеру пакета. Иными словами, если вы пошлете 1 байт, то в результате будет всё равно отправлена целый пакет, в которой из полезных данных будет только 1 байт, а остальное будет мусором. Поэтому по умолчанию умный TCP будет ждать 200 миллисекунд, чтобы пользователь добавил еще данных на отправку, чтобы передать всё сразу. Этот механизм с ожиданием называется "Алгоритм Нейгла" и для игр он совсем не подходит. Поэтому данная настройка как бы говорит протоколу TCP — «слушай, уважаемый, давай не умничай, а отправляй данные сразу». Практически, эта настройка запрещает протоколу TCP использовать "Алгоритм Нейгла".

Теперь, если это сервер, то основной сокет необходимо сразу привязать к порту:

sockaddr_in address;
uv_ip4_addr("0.0.0.0", НОМЕР ПОРТА СЕРВЕРА, &address);

uv_tcp_bind(tcp_socket, (const struct sockaddr*)&address, 0);

В данном случае в качестве адреса указывывается «0.0.0.0», что означает, что сокет будет привязан ко всем сетевым адаптерам, которые присутствуют на компьютере, а не только к какому-то одному. НОМЕР ПОРТА СЕРВЕРА должен быть выбран самостоятельно и тут главным является его уникальность, чтобы не создавать конфликта с другими программами.

Далее сервер должен включить для сокета прослушку порта:

uv_listen((uv_stream_t*)tcp_socket, 1024, OnAccept);

Первый параметр tcp_socket — это, понятное дело, сам сокет, а вот второй куда более специфичен. Это максимальное количество запросов на соединение, которые могут ожидать своей очереди. Представьте, что у вас супер-популярный сервер и игроки наперебой рвутся на нем поиграть, т.е. запросов на соединение от клиентов приходит очень много. Если сервер не успевает на них отвечать, то он ставит их в очередь. И вот это число 1024 в данном случае и есть максимальный размер этой очереди. Тем, кто не помещается в очередь, сервер будет отвечать тремя словами.
Третий параметр OnAccept — это callback-функция, которая будет вызвана, когда от какого-нибудь клиента придет запрос на соединение CONNECT, для чего клиент использует функцию uv_tcp_connect().

Функция OnAccept() может выглядеть примерно так:

void OnAccept(uv_stream_t *server, int status)
{
    if (status<0)
    {
        // Ошибка установки соединения
        return;
    }
    // Сервер создает новый сокет, чтобы через него установить соединение с клиентом
    uv_tcp_t* tcp_socket=malloc(sizeof(uv_tcp_t));
    memset(tcp_socket, 0, sizeof(uv_tcp_t));
    uv_tcp_init(&loop, tcp_socket);
    if (uv_accept(server, (uv_stream_t*)tcp_socket)==0) // делается попытка установить соединение
    {
	// Соединение установлено
        uv_read_start((uv_stream_t*)tcp_socket, OnAllocBuffer, OnReadTCP); // Переводим сокет в режим чтения
    }
    else
    {
	// Соединение установить не удалось
        uv_close((uv_handle_t*) tcp_socket, OnCloseSocket); // Удаляем сокет, который был создан для соединения
    }
}

Чуть подробнее разберем внутренности функции OnAccept().

  1. Сначала создается новый сокет, который будет использован для соединения с клиентом, от которого пришел запрос на установку соединения. Для установки соединения клиент использует uv_tcp_connect().
  2. Вызывается uv_accept(), которая выполняет установку соединения между сер-вером и клиентом. Функция uv_accept() вызывает на клиенте срабатывание call-back-функции, указанной в uv_tcp_connect() в качестве последнего параметра. В примере выше это была функция OnConnect().
  3. Если соединение успешно установлено, то сервер должен позволить созданному сокету читать данные из этого соединения. Функция uv_read_start() включает чтение данных для только что созданного сокета tcp_socket. Обратите внимание, что «чтение данных» и «прослушивание сокета» — это разные операции. «Чтение данных» — это в прямом смысле «чтение» по аналогии с побайтным чтением из файла, а «прослушивание сокета» — это ожидание запроса CONNECT для установки соединения.

    Функция uv_read_start() использует целых две callback-функции:

    OnAllocBuffer() вызывается перед чтением данных и просит пользователя указать память для приема данных.

    Сама функция определена так:

    void OnAllocBuffer(uv_handle_t* handle, size_t suggested_size, uv_buf_t* buf)

    Первый параметр uv_handle_t* handle — это указатель на сокет, которому требуется память.

    Второй параметр size_t suggested_size — это требуемый размер буфера.

    Третий параметр uv_buf_t* buf — это структура, через которые пользователь возращает библиотеке libuv информацию о буфере (размер и адрес).

    buf->len=РАЗМЕР;
    buf->base=АДРЕС;

    Короче говоря, сам буфер должен создаваться пользователем и удаляться тоже пользователем. А libuv будет только принимать в этот буфер данные. Один и тот же буфер может быть использован для множества сокетов.
    В моем случае, libuv почему-то всегда запрашивала буфер размером 65536 байт. Как по мне, так это немного странно, но так как я выделяю эту память 1 раз, то вроде бы ничего страшного в этом нет.

    OnReadTCP() вызывается после OnAllocBuffer(), чтобы передать пользователю данные, которые сокет принял в буфер.

    Сама функция определена так:

    void OnReadTCP(uv_stream_t* stream, ssize_t nread, const uv_buf_t* buf)

    Первый параметр uv_handle_t* handle — это указатель на сокет, который принял данные.

    Второй параметр ssize_t nread — это количество принятых байтов. Если nread меньше или равен нулю, то это признак того, что соединение было разорвано. Т.е. это не чтение данных, а информация о обрыве связи, который может произойти из-за того, что вторая сторона удалила сокет, относящийся к данному соединению или по аппаратным причинам типа «оборыв кабеля». Данную ситуацию обязательно необходимо отслеживать и реагировать на неё соответствующим образом.

    Третий параметр const uv_buf_t* buf — содержит адрес буфера с прочитанными данными. Это будет тот же самый адрес, который пользователь указал в функции OnAllocBuffer().

    Важнейший момент при чтении данных. Как я уже писал ранее, протокол TCP может доставлять отправленное сообщение по частям, например, если отправитель послал фразу «Привет,», а затем фразу «Сервер», то совсем необязательно, что при получении этих двух сообщений информация будет выглядеть точно также, как и в отправляемом виде. Во-первых, оба сообщения могут слипнуться в одно и тогда принимающая сторона получит одно цельное сообщение «Привет, Сервер», или может быть даже так: сначала «При», потом «вет, Сер», а потом «вер». Т.е. сообщение в теории может быть раздроблено на сколько угодно частей. Поэтому у всякого сообщения передаваемого по сети всегда должен быть заголовок, который позволит отделять в потоке приходимых на сокет байт одно сообщение от другого. Из всего этого следует очень неприятная особенность чтения данных из TCP-сокета. Практически, каждый такой сокет должен иметь собственный буфер, куда он будет складывать приходящие байты очередного сообщения, ведь если сообщение получено лишь частично, то требуется подождать, когда оно будет принято полностью и только потом уже можно на него как-то реагировать.

  4. Если соединение установить не удалось, тогда предназначенный для него сокет нужно удалить. Для этого используется функция uv_close(). Но… обратите вни-мание, что эта функция также принимает в качестве последнего параметра call-back-функцию OnCloseSocket(). И именно в момент вызова этой функции libuv сообщает пользователю, что теперь сокет можно удалить из памяти физически.
    void OnCloseSocket(uv_handle_t* handle)
    {
    	free(handle); // сокет был создан через malloc(), теперь его нужно удалить через free()
    }

Отправка сообщений

Для отправки сообщения через сокет служит функция uv_write().

int uv_write(uv_write_t* req, uv_stream_t* handle, const uv_buf_t bufs[], unsigned int nbufs, uv_write_cb cb);

Превый параметр uv_write_t* req — это какая-то переменная, которая нужна libuv для передачи данных. Вникать в смысл её присутствия в параметрах функции особо не стоит, но нужно её создать, например, так:

uv_write_t write_data;

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

Второй параметр uv_stream_t* handle — это сокет-отправитель.

Третий параметр const uv_buf_t bufs[] — это массив сообщений для отправки. Он состоит из элементов типа uv_buf_t, у которых есть поля len и base, что соответственно, должно содержать РАЗМЕР и АДРЕС.

Четвертый параметр unsigned int nbufs — количество элементов в массиве сообщений. В моем случае я всегда использовал только одно сообщение для отправки.

Пятый параметр uv_write_cb cb — это функция обратного вызова, которая вызывается, когда сообщение отправлено. Зачем она вообще нужна? Дело в том, что сообщения, которые отправляет пользователь должны храниться в памяти до тех пор, пока они не были отправлены. Т.е. когда срабатывает эта callback-функция, то это означает, что буфер данных, который содержал отправляемое сообщение теперь больше не нужен libuv. И теперь этот буфер опять переходит под контроль пользователя и его можно заполнить новыми данными и послать новое сообщение.
В моем случае я в одну структуру поместил и буфер данных, и непонятную, но нужную uv_write_t write_data. Поэтому они работают в паре в составе одной структуры.

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

Представим себе, что у вас есть множество сокетов типа uv_tcp_t, которые принимают сообщения. Как я сказал чуть выше, из-за того, что чтение данных происходит по принципу потока и сообщения могут произвольно делиться на части, то нам дополнительно для каждого сокета понадобится буфер для хранения и анализа входящих данных. А вот теперь посмотрим еще раз на функцию обратного вызова OnReadTCP():

void OnReadTCP(uv_stream_t* stream, ssize_t nread, const uv_buf_t* buf)

и заметим, что она через параметр uv_stream_t* stream показывает нам сокет, который получил данные. Но… как вы свяжете сокет с нужным буфером данных? Допустим у вас 1000 сокетов и каждый чего-то там принимает. Вам нужно для каждого сокета положить принятые данные в его собственный буфер. Но ведь по сокету его буфер никак не определяется — внутри структуры сокета будет набор какой-то галиматьи без всяких признаков того, какому же из тысячи имеющихся буферов он соответствует.

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

Допустим у нас есть класс сервера под названием GNetServer. Объект этого класса всегда существует в единственном экземпляре и полностью берет на себя функции сервера. В моем конкретном случае сервер должен иметь два основных массива это:

  • Массив игроков
  • Массив существующих сеансов (или массив созданных игр)

Между этими двумя массивами должны быть установлены тесные связи, т.е. любой игрок должен знать, к какому сеансу он относится (если он уже присоединился к сеансу), а также любой сеанс должен знать, какие игроки входят в его состав.

Какие требования предъявляются к игрокам и сеансам? Главное требование — это быстрый доступ к нужному игроку или сеансу по его личному уникальному идентификатору. А ничего быстрее для поиска, чем просто сделать этот иднтификатор индексом игрока или сеанса в массиве придумать, наверное, невозможно. Итак, у нас каждый игрок имеет ID, который просто равен его номеру в общем массиве игроков. И теперь если другой игрок отправляет данные игрокам с идентификаторами 5, 10, 21 и 115, то сервер сразу сможет определить этих получателей просто используя их идентификаторы как индексы.

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

  • Идентификатор игрока (он же индекс в массиве игроков)
  • Буфер для принимаемых данных
  • Время создания сокета
  • Время последнего получения данных на сокет
  • Признак UDP/TCP
  • Имя игрока (требуется разве что для отладки и общей информации)
  • Еще кое-какая не очень важная мелочь

Вся эта информация хранится в классе GNetSocket. Но обратите внимания, что в ней нет никамих данных от libuv. Сделано это для того, чтобы при некотором желании можно было заменить libuv на что-нибудь другое.

С моем случае имеется класс GNetSocketLibUV, который наследуется от GNetSocket. Для чего мне понадобился промежуточный класс GNetSocket, если в реальности создаются только объекты класса GNetSocketLibUV? Дело в том, что моя задача была максимально отделить библиотеку libuv от общей структуры сети. В результате основной файл сервера/клиента занимает более 7 тысяч строк, а файл специфичный для libuv — 600 строк. И если потребуется заменить libuv, то я смогу это сделать относительно легко. Также я использую принцип, когда объекты ключевых классов создаются не напрямую через new, а через виртуальные функции, например, так создается объект сокета для сервера:

GNetSocket* GNetServerLibUV::NewSocket(GNet* net)
{
	return new GNetSocketLibUV(net);
}

Т.е. стоит мне заменить в одном месте return new GNetSocketLibUV(net); на какой-то другой тип объекта, как в программе будет создаваться только этот тип.

Класс GNetSocketLibUV переопределяет все абстрактные функции базового класса. Выглядит он так:

class GNetSocketLibUV : public GNetSocket
{
public:
	void* sock;

public:
	GNetSocketLibUV(GNet* net);
	virtual ~GNetSocketLibUV();

	// Создает UDP или TCP-сокет и, если указан listaen=true, то создается слушающий сокет
	virtual bool Create(bool udp_tcp, int port, bool listen);

	// Установка приконнекченного к серверу TCP-сокета в режим чтения
	virtual bool SetConnectedSocketToReadMode();

	// Удаляет сокет
	virtual void Destroy();

	// Определение IP-адреса для подключенного TCP-сокета
	// own_or_peer показывает собственный или адрес с которым установлена связь нужно вернуть
	virtual bool GetIP(CMagicString& addr, bool own_or_peer);

	// Клиент запрашивает подключение к серверу (только для TCP-сокетов)
	virtual bool Connect(NET_ADDRESS* addr);

	// Сервер отвечает на подключение (только для TCP-сокетов)
	virtual bool Accept();
	
	virtual void SendTCP(NET_BUFFER_INDEX* buf);
	virtual void SendUDP(NET_BUFFER_INDEX* buf);

	virtual void ReceiveTCP();
	virtual void ReceiveUPD();
};

В класс GNetSocketLibUV добавлена всего одна переменная void* sock, которая и будет являться почти что указателем на сокет libuv, но с некоторой оговоркой. Нам нужна возможность быстро по сокету libuv определять соответствующий ему сокет типа GNetSocket, в котором как раз лежит и буфер для чтения, и идентификатор игрока.
Как это сделать? Я добавил промежуточные структуры:

struct NET_SOCKET_PTR
{
	GNetSocket* net_socket;
};

struct TCP_SOCKET : public NET_SOCKET_PTR, public uv_tcp_t
{
};

struct UDP_SOCKET : public NET_SOCKET_PTR, public uv_udp_t
{
};

Значит так… теперь у нас есть новая структура для TCP-сокета под названием TCP_SOCKET и новая структура для UDP-сокета, которая называется UDP_SOCKET. Но… у обеих этих структур перед структурой сокетов появилось новое поле, которое является указателем на родительский объект класса GNetSocket.
Теперь еще одно важное замечание. В программе нигде не должны создаваться «родные» сокеты libuv, а только сокеты типа TCP_SOCKET и UDP_SOCKET. Сразу после создания в поле net_socket должен записываться адрес объекта GNetSocket, в составе которого создавался TCP_SOCKET или UDP_SOCKET.

Практически, создание сокета выглядит так:

// Создает UDP или TCP-сокет и, если указан listen=true, то создается слушающий сокет
bool GNetSocketLibUV::Create(bool udp_tcp, int port, bool listen)
{
	GNetSocket::Create(udp_tcp, port, listen);

	uv_loop_t* loop=GetLoop(net);

	if (udp_tcp)
	{
		sock=malloc(sizeof(TCP_SOCKET));
		memset(sock, 0, sizeof(TCP_SOCKET));
		((TCP_SOCKET*)sock)->net_socket=this;
...
...
...
}

Теперь, когда у нас void* sock является адресом TCP_SOCKET или UDP_SOCKET, и мы всегда знаем, что первыми в структуре всегда будет стоять указатель на основной сокет GNetSocket* net_socket, то задача по «быстрой установке соответствия» почти решена.

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

Если sock — это TCP_SOCKET, то передав адрес sock в следующую функцию легко извлекается TCP-сокет libuv:

uv_tcp_t* GetPtrTCP(void* ptr)
{
	return (uv_tcp_t*)(((char*)ptr)+sizeof(void*));
}

Если sock — это UDP_SOCKET, то передав адрес sock в следующую функцию легко извлекается UDP-сокет libuv:

uv_udp_t* GetPtrUDP(void* ptr)
{
	return (uv_udp_t*)(((char*)ptr)+sizeof(void*));
}

А функция GetNetSocketPtr(адрес uv_tcp_t или uv_udp_t сокета) позволяет получить соответствующий этому сокету адрес нашего основного сокета типа GNetSocket.

GNetSocket* GetPtrSocket(void* ptr)
{
	return *((GNetSocket**)ptr);
}

GNetSocket* GetNetSocketPtr(void* uv_socket)
{
	return GetPtrSocket(((char*)uv_socket)-sizeof(void*));
}

Как этим пользоваться на практике?
Например, нужно перевести TCP-сокет в режим чтения:

// Установка приконнекченного к серверу TCP-сокета в режим чтения
bool GNetSocketLibUV::SetConnectedSocketToReadMode()
{
	if (udp_tcp)
	{
		uv_tcp_t* tcp=GetPtrTCP(sock);
		int r=uv_read_start((uv_stream_t*)tcp, OnAllocBuffer, OnReadTCP);
		return (r==0);
	}
	return false;
}

Обратите внимание, что наш sock превращается в uv_tcp_t* tcp с помощью GetPtrTCP(sock), а уже его можно передавать в функцию uv_read_start().

Теперь как в моем случае выглядит callback-функция OnReadTCP():

void OnReadTCP(uv_stream_t* stream, ssize_t nread, const uv_buf_t* buf)
{
	GNetSocket* socket=GetNetSocketPtr(stream);

	if (nread>0)
	{
		NET_BUFFER* recv_buffer=socket->net->GetRecvBuffer();
		assert(buf->base==(char*)recv_buffer->GetData());
		recv_buffer->SetLength(nread);
		socket->ReceiveTCP();
	}
	else
	{
		// это ошибка, значит связь разорвана
		socket->net->OnLostConnection(socket);
	}
}

Первая же строчка

GNetSocketLibUV* socket=(GNetSocketLibUV*)GetNetSocketPtr(stream);

получает адрес объекта GNetSocket, для которого будет выполнена функция socket->ReceiveTCP(), которая и осуществляет реальный прием сообщений полученных сокетом. Она уже положит эти данные в собственный буфер сокета, проверит на тот факт, что сообщение получено полностью и затем передаст его на обработку серверу либо клиенту (у меня большая часть кода у сервера и клиента совпадает).

Еще, пожалуй, приведу пример удаления сокета:

// Удаляет сокет
void GNetSocketLibUV::Destroy()
{
	if (sock)
	{
		if (udp_tcp)
		{
			uv_tcp_t* tcp=GetPtrTCP(sock);
			uv_close((uv_handle_t*)tcp, OnCloseSocket);
			((TCP_SOCKET*)sock)->net_socket=NULL;
		}
		else
		{
			uv_udp_t* udp=GetPtrUDP(sock);
			int r=uv_read_stop((uv_stream_t*)udp);
			assert(r==0);
			uv_close((uv_handle_t*)udp, OnCloseSocket);
			((UDP_SOCKET*)sock)->net_socket=NULL;
		}
		sock=NULL;
	}

	GNetSocket::Destroy();
}

Обратите здесь внимание на строку

((TCP_SOCKET*)sock)->net_socket=NULL;

, т.е. сокет libuv здесь не удаляется, он становится вообще сам по себе, так как основной объект GNetSocket уже не будет иметь связи с этим сокетом. Но когда libuv завершит все свои дела с сокетом, то неминуемо сработает callback-функция OnCloseSocket(), в которой и будет выполнен free(). Таким образом утечки памяти не произойдет.

На этом, я думаю, что разговор про библиотеку libuv можно заканчивать. Я постарался пояснить суть принципов, которые лежат в основе её работы и, думаю, что принципы эти, практически, одинаковы для большинства подобных решений, включая WinSock. Возможно в моей трактовке не хватает примеров кода, но их не очень сложно найти в Интернете по названиям функций. Я же пытался пояснить, что с этими функциями нужно делать и как они взаимодействуют друг с другом. Вполне возможно, что в моем понимании есть какие-то неточности, так как моей целью было доделать игру, а не стать экспертом в области сетевого программирования, поэтому я разобрался с этим делом по принципу «необходимо и достаточно».

Структура сетевого клиента GNetClient

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

В моем случае имеются следующие стадии:
GNetStadyEnumSession / NET_STADY_ENUM_SESSION — стадия получения списка существующих игровых сеансов.
GNetStadyJoinSession / NET_STADY_JOIN_SESSION — стадия подключения в игровому сеансу и настройки собственного игрока.
GNetStadyCreateSession / NET_STADY_CREATE_SESSION — стадия, которая позволяет создать сеанс, настраивать его и собственного игрока.
GNetStadyStartGame / NET_STADY_START_GAME — стадия запуска сетевой игры.
GNetStadyGame / NET_STADY_GAME — стадия игры.
GNetStadyMigrationHost / NET_STADY_MIGRATION_HOST — стадия миграции хоста.

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

enum NET_STADY {NET_STADY_ENUM_SESSION, NET_STADY_JOIN_SESSION, NET_STADY_CREATE_SESSION, NET_STADY_START_GAME, NET_STADY_GAME, NET_STADY_MIGRATION_HOST, NET_STADY_NO=-1};

Объект каждой стадии создается заранее и хранится в массиве стадий внутри GNetClient.

// Класс, который является сетевым клиентом
class GNetClient : public GNet
{
protected:
	NET_STADY net_stady;		// текущая стадия
	int k_net_stady;			// количество стадий
	GNetStady** m_net_stady;	// массив со стадиями

...
...
...
}

GNetClient::GNetClient() : GNet()
{
	net_stady=NET_STADY_NO;

	k_net_stady=6;
	m_net_stady=new GNetStady*[k_net_stady];
	m_net_stady[NET_STADY_ENUM_SESSION]=new GNetStadyEnumSession(this);
	m_net_stady[NET_STADY_JOIN_SESSION]=new GNetStadyJoinSession(this);
	m_net_stady[NET_STADY_CREATE_SESSION]=new GNetStadyCreateSession(this);
	m_net_stady[NET_STADY_START_GAME]=new GNetStadyStartGame(this);
	m_net_stady[NET_STADY_GAME]=new GNetStadyGame(this);
	m_net_stady[NET_STADY_MIGRATION_HOST]=new GNetStadyMigrationHost(this);
...
...
...
}

Базовым для всех стадий является класс GNetStady, от которого наследуются все остальные стадии:

// Класс, работающий со стадиями сетевой игры
class GNetStady
{
protected:
	GNetClient* owner; // указатель на родительский объект-клиент GNetClient

	unsigned int stady_period;
	unsigned int stady_tick;

public:
	GNetStady(GNetClient* owner);
	virtual ~GNetStady(){}

	virtual bool OnStart(NET_STADY previous, void* init);
	virtual void OnFinish(NET_STADY next){}

	virtual void OnUpdate(); 

	// Функция вызывается периодически через время, которое определяется переменной stady_period (если она не равна 0)
	virtual void OnPeriod(){}

	// Проверка на корректность полученного сообщения
	virtual bool IsMessageCorrected(int message_type){return false;}
};

Пройдемся коротко по функциям стадии.

  • virtual bool OnStart(NET_STADY previous, void* init)

    Функция выполняется когда стадия становится текущей. Практически, это инициализация стадии, которая выполняется в момент её активации. Если эта функция возвращает false, то это означает, что сделать данную стадию текущей невозможно. В качестве параметров эта функция получает индекс предыдущей стадии NET_STADY previous и произвольные данные для инициализации void* init, которые почти нигде не используются.
    Выбор новой стадии происходит с помощью bool GNetClient::SetStady(NET_STADY stady, void* init).

  • virtual void OnFinish(NET_STADY next)

    Функция выполняется, когда стадия перестает быть текущей, т.е. эта функция служит для того, чтобы выполнить какие-то действия по деактивации стадии. Часто она просто является пустой функцией.

    Пользователь для выбора новой стадии вызывает функцию SetStady():

    // Установка стадии
    bool GNetClient::SetStady(NET_STADY stady, void* init)
    {
    	if (stady!=net_stady)
    	{
    		if (net_stady!=NET_STADY_NO)
    		{
    			// необходимо завершить предыдущую стадию
    			m_net_stady[net_stady]->OnFinish(stady);
    		}
    		if (stady!=NET_STADY_NO)
    		{
    			// необходимо инициализировать новую стадию
    			if (!m_net_stady[stady]->OnStart(net_stady, init))
    				return false;
    		}
    		net_stady=stady;
    	}
    	return true;
    }

  • virtual void OnUpdate()

    Функция, которая обслуживает работу стадии. Эта функция вызывается постоянно, совместно с обновлением состояния игры.

    Вызов GNetStady::OnUpdate() выполняется из GNetClient::OnUpdate():

    void GNetClient::OnUpdate()
    {
    	if (net_stady!=NET_STADY_NO)
    		m_net_stady[net_stady]->OnUpdate();
    }

  • virtual void OnPeriod()

    Функция, которая вызывается только в случае, когда стадия должна выполнять какие-то периодические действия. Периодичность определяется значением переменной GNetStady::stady_period, которая должна содержать частоту вызова OnPeriod(). Например, если stady_period=500, то это означает, что вызов функции OnPeriod() будет происходить примерно 1 раз в полсекунды.
    На самом деле функция OnPeriod() осуществляется из OnUpdate(), которая для базового класса определена так:

    void GNetStady::OnUpdate()
    {
    	if (stady_period)
    	{
    		unsigned int tick=owner->game->platform->GetTick();
    		if (tick>=stady_tick+stady_period)
    		{
    			stady_tick=tick;
    			OnPeriod();
    		}
    	}
    }

  • virtual bool IsMessageCorrected(int message_type)

    Функция, которая проверяет на корректность принимаемого сообщения по его типу. Естественно, что каждое сетевое сообщение состоит из заголовка, типа и данных. Эта функция как раз и проверяет этот тип полученного сообщения на правильность для данной стадии. Если тип сообщения корректен, то он будет отправлен на обработку, иначе сообщение будет просто отброшено.
    Зачем это нужно? Ну, во-первых, мало ли что может прийти по сети. А, во-вторых, сетевое взаимодействие имеет много общего с многопоточностью, т.е. после отправки сообщения, его получением и обработкой принимающей стороной может лежать N-ое количество времени.
    Например, когда игроки меняют свои настройки в сеансе, то сообщения о новых настройках передаются всем остальным участникам сеанса. А теперь представьте, что какой-то игрок покинул сеанс (нажал Cancel) и перешел на стадию NET_STADY_ENUM_SESSION, чтобы выбрать себе другой сеанс. Тогда в момент выхода из сеанса он тоже уведомит других игроков о своем уходе, но проблема в том, что пока те получат его сообщение и среагируют на него проходит время. За этот период времени остальные игроки запросто могут продолжать слать уже покинувшему сеанс игроку сообщения о настройках сеанса. И именно эти уже ненужные сообщения ушедший игрок должен безжалостно отсекать.

Управление стадиями сетевого клиента

Всегда какая-то стадия является текущей, за исключением ситуации, когда клиент еще не подключился к серверу.

Для начала работы с сетью используется функция:

// Инициализация сетевой игры
virtual bool GGame::StartNet(const char* ip);

Если ip=NULL, то запускается собственный локальный сервер, иначе ip должен быть IP-адресом Интернет-сервера, например, 234.123.34.18:57.

Для завершения работы с сетью имеется функция:

// Завершение сетевой игры
virtual void StopNet();

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

Для начала работы с сетью нужно вызвать функцию bool GGame::StartNet(const char* ip). Если указан ip-адрес, то функция инициализирует сеть и попробует установить соединение с сервером, находящимся по указанному адресу. Если сервер ответил и разрешил соединение, то функция вернёт true, иначе false. Все дальнейшие обращения к серверу будут выполняться через установленное TCP-соединение. Если же ip=NULL, то никакого подключения к серверу функция не выполняет. Вместо этого она создает UDP-сокет, чтобы с его помощью искать сервера в локальной сети через широковещательный запрос.

Если при выполнении функции StartNet() не возникло проблем, то сеть начинает выполнять стадию NET_STADY_ENUM_SESSION, т.е. искать созданные игровые сеансы. Происходит это следующим образом… в функции GNetStadyEnumSession:: OnPeriod(), которая автоматически вызывается 1 раз в полсекунды, происходит отправка сообщения типа MESSAGE_TYPE_ENUM_SESSION. Сообщение посылается либо только Интернет-серверу по заранее установленному соединению, либо широковещательным запросом в локальную сеть. В любом случае если Интернет-сервер или какой-то сервер в локальной сети получают это сообщение, то они реагируют одинаково, а именно: шлют вопрошающему ответ в виде сообщения MESSAGE_TYPE_ENUM_SESSION_REPLY, в котором перечислены все имеющиеся на сервере сеансы. Каждый сеанс имеет краткое описание в виде времени создания сеанса, ID хоста, имени карты, количества игроков и их характеристик, признак пароля на сеансе, а также IP-адрес сервера, через который клиент сможет подключиться — это очень важно для локальной сети, так как клиент в этом случае отправляет запрос широковещательно и он должен знать адрес сервера, который прислал ему ответ, чтобы произвести подключение.

Получив сведения о найденных сеансах, клиент вызывает виртуальную функцию virtual void GGame::OnEnumSession(GSessionList* sessions, int count_general_session). Эта функция в классе GGame не определена, так как её задача, практически, отобразить список сеансов пользователю. Так как GGame — это универсальный базовый класс, то он не берет на себя такие задачи, так как ничего не знает о конкретной игре, где он может использоваться. Поэтому данная функция в моем случае переопределяется в классе GGameOnimodLand и именно она сортирует полученные сеансы по времени создания и показывает их пользователю добавляя строки в компонент GListBox.

Если пользователь выбрал сеанс и жмёт "Присоединиться", то происходит следующее. Клиент просто запускает стадию NET_STADY_JOIN_SESSION, которая в своей функции GNetStadyJoinSession::OnStart(), делает попытку подключиться к сеансу. Эта попытка может быть неудачной по разным причинам, например, игра была запущена или подключился другой игрок, и места в сеансе больше нет. В любом случае разрешение на подключение выдает хост (ни в коем случае не сервер).

Как это происходит практически?
В случае с локальной сетью сначала необходимо подключиться к серверу. Для этого используется функция bool GNetClient::ConnectToServer(const char* ip), которая в случае успеха возвращает true. IP-адрес сервера берется из информации о сеансе. Перед установкой соединения сначала убивается UDP-сокет, так как он теперь не нужен и вместо него создается TCP-сокет, через который и будет устанавливаться соединение. Клиент обращается к серверу с помощью CONNECT, а сервер должен принять запрос и отреагировать через ACCEPT. Если соединение установлено, то необходимо обменяться "рукопожатием".
Для чего это нужно? Дело в том, что к серверу может подключиться кто угодно из совесем другой программы. "Рукопожатие" идентифицирует клиента в глазах сервера и именно после успешного "рукопожатия" сервер позволяет клиенту начать обмен сообщениями. Само "рукопожатие" выполняется через сообщение MESSAGE_TYPE_HELLO, которое дополнительно снабжается всякой служебной информацией типа версия клиента, лицензионный ключ и т.д. Сервер проверяет эту информацию и возвращает клиенту сообщение MESSAGE_TYPE_HELLO_REPLY, которое содержит уникальный ID клиента. Всё дальнейшее общение с сервером и другими игроками клиент выполняет через этот ID. Если сервер вернул ID=0, то это означает, что клиент не принят сервером. В этом случае сервер дополнительно передает текст, в котором поясняет клиенту причину отказа. Этот текст может быть отображен на стороне клиента в виде, например, GMessageBox. Обратите внимание, что к Интернет-серверу подключение происходит по аналогичному принципу.

Если клиент подключен к серверу, то он сразу должен передать хосту просьбу о подключении к сеансу. Однако хост может защитить сеанс паролем, чтобы подключались только "свои". Из информации о сеансе определяется факт наличия такого пароля. В этом случае пользователю сначала должно быть предложено ввести пароль. Далее выполняется функция bool GNetClient::ConnectSession(NET_JOIN_DATA* join), которая в свою очередь отправляет хосту сообщение типа MESSAGE_TYPE_CONNECTING. Сообщение отправляется на ID хоста, который опять же берется из информации о сеансе. Сначала сообщение получает сервер, который анализирует список получателей и передает сообщение хосту.

Получив сообщение, хост смотрит, есть ли возможность принять игрока в сеанс, проверяет пароль (если он имеется) и принимает решение. В случае положительного ответа хост сначала выбирает для нового игрока игровой слот с помощью виртуальной функции virtual int GGame::GetIndexOfConnectedPlayer(), и включает игрока в сеанс. Дополнительно у хоста срабатывает функция virtual void GGame::OnNewPlayer(int index), которая позволяет проделать какие-то действия из-за появления нового участника. Далее хост посылает клиенту ответ в виде сообщения MESSAGE_TYPE_CONNECT_REPLY, где он, в случае положительного ответа, передает назначенный клиенту индекс слота в сеансе, всю информацию о состоянии других игроков в сеансе, а также некоторую дополнительную информацию. Получив сообщение MESSAGE_TYPE_CONNECT_REPLY, клиент запоминает свой слот и инициализирует свои переменные сеанса полученными от сервера данными.

Теперь новый игрок может настраивать свои характеристики в сеансе и обмениваться чат-сообщениями. Чтобы хост мог запустить игру каждый участник сеанса должен нажать кнопку "Я готов", без этого у хоста кнопка "Старт" будет находиться в неактивном состоянии.

Информация об игровом сеансе

Информация о сеансе должна быть организована так, чтобы не зависеть от конкретной игры. Например, у меня нет никакого желания менять структуру у своей оболочки, если вдруг я захочу так сказать «вспомнить детство золотое» и снова заняться играми. Значит нужно сделать так, чтобы поля в описании сеанса были произвольными и их анализом занималась только конкретная игра, но никогда не сервер или клиент. Однако некоторые поля всё же должны быть четко определены, так как кое-что сервер всё же обязан знать о сеансе, например, его имя. Также нужно однозначно трактовать некоторую информацию об игроках, входящих в сеанс, например, количество и статус "кто это" (человек, компьютер, открытый слот, закрытый слот).

Всю эту информацию я поместил в структуру struct NET_SESSION_INFO. В составе этой структуры есть массив с игроками NET_PLAYER** m_player; и их количество int k_player;
Но самое важное — имеются поля, которые могу хранить произвольную информацию:

int length_info; // размер доп. данных
char* info;      // массив доп. данных

Такие же поля имеются в структуре NET_PLAYER и они также позволяют хранить произвольную информацию для любого игрока.

Далее, в структуру NET_SESSION_INFO добавляем следующие функции и операторы:

virtual void Serialize(CMagicStream& stream); // запись/чтение структуры из потока

NET_SESSION_INFO& operator=(const NET_SESSION_INFO& si); // оператор присваивания
bool operator==(const NET_SESSION_INFO& si); // оператор проверяющий на равенство
bool operator!=(const NET_SESSION_INFO& si) // оператор проверяющий на неравенство

Зачем всё это нужно?
Постараюсь пояснить одну важную штуку, которая, как я думаю, понятна далеко не всем. Применение операторов, на мой взгляд, является довольно опасным делом. Проблема в том, что очень часто в структуру по ходу разработки добавляются новые переменные. И часто их даже можно забыть проинициализировать в конструкторе. А уж позабыть про то, что надо эти переменные еще и добавить в оператор сравнения и равенства — это со мною бывало не раз, после чего я иногда очень гневался на свою «тупую башку» за забывчивость. Так вот… в результате я пришел к следующему решению. Обычно важные структуры имеют в своем составе сериализацию или, проще говоря, умеют читать свои данные из потока и писать их в поток. Чаще всего поток — это обычный файл, куда пишутся байты, но будет гораздо лучше, если потоком сможет быть также и область памяти. В моем случае я написал класс CMagicStream и из него породил классы CMagicStreamFile и CMagicStreamMemory. Поэтому функция virtual void Serialize(CMagicStream& stream); умеет работать и с файлами, и с ОЗУ в зависимости от того, объектом какого класса является в реальности stream.

К слову говоря, есть у меня и еще один тип класса CMagicStreamVirtualFile, который входит в мою «оболочку» и предназначен для работы с виртуальным диском. Виртуальный диск — это парочка файлов, внутри которых находится собственная файловая система. Я использовал свой вирутальный диск, чтобы разместить внутри него ресурсы игры. Виртуальный диск можно открыть через CMagicString GPlatform::OpenVirtualDrive(const char* path) указав в качестве пути файл вирутального диска. В результате будет возвращен путь типа :0 который потом можно использовать в оболочке для работы с файлами внутри виртуального диска. Важно здесь то, что функции «оболочки», работающие с файловой системой, будут понимать такой путь и перенаправлять запросы к файлам куда нужно, например, функция CMagicStream* GPlatform::OpenStream(const char* file, int mode) работает по такому универсальному принципу и корректно обратиться на виртуальный диск, если file имеет соответствующий путь, указывающий на него. Это же касается и ситуации с "текущей папкой" — никто не мешает сделать текущей папку на виртуальном диске.

Так вот… вернемся к сути. Я говорил о том, что для меня операторы могут быть опасны из-за моей забывчивости. Чтобы свести риск к минимуму я поступаю так: делаю для структуры функцию сериализации в бинарном виде, потом все операторы пропускаю через эту функцию. Т.е. например, если мне надо написать оператор присваивания, то я вместо того, чтобы копировать поля структуры по одному, создаю в ОЗУ поток типа CMagicStreamMemory на запись и выполняю для него Serialize() копируемого объекта структуры, далее я создаю такой же поток на чтение и для результарующего объекта структуры тоже выполняю Serialize() с этого же участка памяти. Получается такой Save + Load через ОЗУ. Точно также можно поступить и с оператором сравнения — записываем данные сравниваемых объектов в два разных потока, а потом начинаем сравнивать их побайтно. Этот способ разумеется, медленне, чем если бы опреатор сравнивал каждое поле с каждым. Но для узких мест всегда можно использовать традиционный вариант. Достоинство же сериализации в том, что она применяется еще и для обычного Save/Load данных. А тут забывчивость всплывает достаточно быстро, по крайней мере, в моем случае, так как испорченные данные или несохраненные поля гораздо активнее бросаются в глаза.

Кроме того, сериализация прекрасно подходит для копирования своих данных через буфер обмена. А в случае с сетью… обратите внимание, что сетевое взаимодействие сводиться к передачи сообщений, которые представляют из себя поля "тип", "размер" и "произвольные данные". И использвание бинарной сериаллизации как раз и позволяет превратить любые типы данных в поток байтов, который можно передать по сети, а затем снова превратить в исходные данные на принимающей стороне.

Теперь я наконец-то добрался до сути конфигурирования сетевого сеанса. Сетевой клиент не должен ничего знать об игре и не должен знать о большинстве настроек игроков. Например, флаг "Я готов" уж точно ему не нужен — он нужен именно игре, а сетевой клиент служит лишь для передачи сообщений по сети.
Я сделал так. В GGame объявил 4 пустые вирутальные функции, которые должны быть определены уже в классе GGameOnimodLand.

virtual bool GGame::GetSessionInfoStruct(NET_SESSION_INFO* si); // заполняет структуру сеанса NET_SESSION_INFO* si данными из игры.
virtual void GGame::SetSessionInfoStruct(NET_SESSION_INFO* si); // копирует данные из структуры сеанса NET_SESSION_INFO* si в игру.

virtual bool GGame::GetSessionPlayerStruct(int index, NET_PLAYER* np); // заполняет структуру сетевого игрока NET_PLAYER* np данными об этом игроке из игры. Параметр index содержит слот игрока.
virtual void GGame::SetSessionPlayerStruct(int index, NET_PLAYER* np); // копирует данные из структуры сетевого игрока NET_PLAYER* np в игрока в игре.

Практически, эти функции универсально передают данные от сетевого клиента в игру и обратно. При этому функции GetSessionInfoStruct / SetSessionInfoStruct работают с данными всего сеанса, включая и данные об игроках. А функции GetSessionPlayerStruct / SetSessionPlayerStruct работают с данными по конкретному игроку. Естественно, что функции для игроков используются функциями сеансов, так как NET_SESSION_INFO содержит массив объектов NET_PLAYER.

После такого подхода сама игра для сетевого клинета превращается в «черный ящик», из которого «что-то» приходит и который «что-то» принимает. И сейчас важный момент, который нужен для того, чтобы упростить процесс конфигурирования игрока. Вот представьте, что у игрока существует много настроек и что они могут меняться, например, можно менять цвет своей команды или выбирать расу. Да мало ли что можно придумать в перспективе и желательно, чтобы не пришлось потом исправлять структуру программы.
Я сделал так: у стадии NET_STADY_JOIN_SESSION по таймеру вызывается функция void GNetStadyJoinSession::OnUpdate() и она выполняет в паре строк то, что решает проблему универсальности.

Стадия GNetStadyJoinSession имеет переменную NET_SESSION_INFO* copy_session; в которой храниться копия о состоянии сеанса за прошлый такт. Я всё же приведу значимую часть кода целиком.

void GNetStadyJoinSession::OnUpdate()
{
	GNetStady::OnUpdate();

	// необходимо сравнить старую конфигурацию с новой
	NET_PLAYER* current_player=current_session->m_player[index_player];
	NET_PLAYER* copy_player=copy_session->m_player[index_player];
	owner->game->GetSessionPlayerStruct(index_player, current_player);
	if (*copy_player!=*current_player)
	{
		// необходимо переслать новую конфигурацию всем участникам сеанса
		*copy_player=*current_player;

		if (current_player->type==PLAYER_MAN)
		{
			GMemWriter* wr1=owner->wr1;
			wr1->Start();
			(*wr1)<<index_player;
			current_player->Serialize(*wr1);
			MEM_DATA buf;
			wr1->Finish(buf);
			// необходимо извлечь участников сеанса, кроме себя самого
			int k_receiver=owner->RefreshReceiverList();
			NET_BUFFER_INDEX* result=owner->PrepareMessageForPlayers(MESSAGE_TYPE_PLAYER_INFO, buf.length, buf.data, k_receiver, owner->m_receiver);
			owner->GetMainSocket()->SendMessage(result);
		}
	}
}

Практически, тут происходит следующее. Переменная index_player является номером слота, который принадлежит игроку в сеансе.
Строка owner->game->GetSessionPlayerStruct(index_player, current_player); берет из игры текущие настройки этого же игрока и дальше просто сравнивает их с теми, которые помнит клиент:
if (*copy_player!=*current_player)
и если вдруг имеется несоответствие, то сначала копия приводится в соответствие *copy_player=*current_player; а затем всем участникам сеанса отправляет сообщение типа MESSAGE_TYPE_PLAYER_INFO, в котором передаются новые настройки игрока.

В чем большое преимущество такого подхода? Дело в том, что сама игра вообще не должна следить за отправкой конфигурации другим игрокам. Стоит поменять в конфигурации хотя бы 1 байт, как GNetStadyJoinSession::OnUpdate() сразу заметит это изменение и автоматически разошлет новые данные всем участникам сеанса. При этом GNetStadyJoinSession::OnUpdate() ничего не знает про реальные данные, которые могут конфигурироваться, ведь оператор сравнения работает через сериализатор, а там сравнивается длина потока байтов и сам поток с случае равенства длины.

В примере к статье, структура с конфигурацией игрока выглядит так:

struct PlayerCfg
{
	int type;
	CMagicString name;
	unsigned int player_id;
	unsigned int color;
	bool ready;

	void Serialize(CMagicStream& stream)
	{
		if (stream.IsStoring())
		{
			stream<<type;
			stream<<name;
			stream<<player_id;
			stream<<color;
			stream<<ready;
		}
		else
		{
			stream>>type;
			stream>>name;
			stream>>player_id;
			stream>>color;
			stream>>ready;
		}
	}
};

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

Создание сеанса

Однако, прежде чем присоединяться к сеансу, необходимо, чтобы кто-то этот сеанс создал. Для этого существует стадия NET_STADY_CREATE_SESSION.
Класс этой стадии наследуется от стадии NET_STADY_JOIN_SESSION:

class GNetStadyCreateSession : public GNetStadyJoinSession

Это сделано по причине того, что эти стадии во многом похожи и серьезно отличаются разве что функцией OnStart(), которая выполняет либо создание сеанса, либо присоединение к сеансу. В стадии NET_STADY_CREATE_SESSION точно также работает проверка на изменение конфигурации, но дополнительно имеется проверка и на изменение каждого игрока в сеансе, ведь хост контролирует весь сеанс целиком и может, например, удалять других игроков.

Кстати, сетевой клиент всё же немного участвует в анализе данных о конфигурации игрока. За конфигурацию отвечает сообщение MESSAGE_TYPE_PLAYER_INFO, которое клиент анализирует следующим образом. В момент приема сообщения он запоминает был ли игрок, для которого пришла новая конфигурация, живым человеком (type=PLAYER_MAN). После приема сообщения текущая конфигурация заменяется на новую. Но клиент проверяет только поле type на предмет того, что оно всё еще равно PLAYER_MAN. И если вдруг поле изменилось на PLAYER_OPENED, то это может означать, например, что хост удалил игрока из сеанса и теперь слот открыт. Клиент участвует в обработке этой ситуации в результате чего вызывается bool GNetClient::LostPlayer(unsigned int player_id), которая означает что свзяь с игроком потеряна. Далее всё это доходит до игры в виде одного из вариантов:

// Хост покинул сеанс (предназначено для остальных игроков сеанса)
virtual void GGame::OnCancelSession();
// Текущий игрок был отсоединен от сеанса хостом
virtual void GGame::OnDeletingFromSession();

Функция bool GNetStadyCreateSession::OnStart(NET_STADY previous, void* init) вызывается, когда игрок нажимает кнопку «Создать игру». Если это игра по локальной сети, то необходимо сначала запустить собственный сервер и присоединиться к нему:

// Создание игрового сеанса
bool GNetClient::CreateSession()
{
	bool is=false;
	if (!internet)
	{
		// необходимо запустить сервер
		if (StartLocalServer())
		{
			is=ConnectToServer("127.0.0.1");
		}
		else
			is=false;
	}
	else
	{
		is=true;
	}

	return is;
}

В функции ConnectToServer(«127.0.0.1») UDP-сокет уничтожается и создается TCP-сокет. Далее происходит установка соединения с сервером, который запускается на том же самом компьютере функцией StartLocalServer(). IP-адрес сервера равен «127.0.0.1», что означает «тот же самый компьютер».

Далее хост вызывает у себя функцию virtual void GGame::OnCreateSession(int index_player), которая в моем случае, лишь устанавливает для хоста его слот, который всегда равен 0.

Далее хост с помощью функции void GNetStadyCreateSession::OnPeriod() начинает периодически сообщать серверу о состоянии сеанса. Эта функция вызывается автоматически 1 раз в полсекунды. Она отправляет серверу сообщение типа MESSAGE_TYPE_SESSION_INFO. Это сообщение отправляется не всегда, а только лишь в случае, когда настройки сеанса были изменены. Здесь используется тот же принцип, что и с изменением конфигурации игрока.

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

Стадия NET_STADY_CREATE_SESSION длится до тех пор, пока игрок не нажмет кнопку "Старт", чтобы инициировать запуск игры. Практически, в этот момент устанавливается стадия NET_STADY_START_GAME через вызов net->SetStady(NET_STADY_START_GAME, NULL);.
Сообщение MESSAGE_TYPE_START_GAME отправляется автоматически из функции
void GNetStadyCreateSession::OnFinish(NET_STADY next), которая вызывается в момент завершения стадии NET_STADY_CREATE_SESSION. Здесь же хост сообщает серверу, что сеанс теперь закрыт для присоединения и не нужно о нем сообщать другим игрокам.

Сообщение MESSAGE_TYPE_START_GAME передается сервером всем участникам сеанса. Получив его все они также переключаются на стадию NET_STADY_START_GAME.
Далее основная работа стадии запуска игры выполняется в функции:
void GNetStadyStartGame::OnPeriod(), которая выполняет обратный отсчет времени от 5 до 1 через функцию virtual void GGame::OnStartNetCounter(int counter); Практически, происходит вывод цифр 5,4,3,2,1 в область чата. Далее вызывается virtual void GGame::OnStartNetGame(), которая запускает процесс подготовки игрового сеанса к запуску. В этот момент должна загрузиться игровая карта и по ней должны быть расставлены игроки. Обратите внимание, что весь этот процесс выполняется на каждом компьютере независимо. Когда все данные будут инициализированны, то функция virtual bool GGame::IsNetGameLoaded() должна вернуть true. Эта функция вызывается постоянно из GNetStadyStartGame::OnPeriod() и пока возвращается false, сетевой клиент полагает, что инициализация игры продолжается. Как только возвращается true, тут же всем остальным игрокам отправляется сообщение MESSAGE_TYPE_PLAYER_STARTED. В тот момент, когда сетевой клиент обнаруживает, что получил сообщение MESSAGE_TYPE_PLAYER_STARTED уже от всех участников сеанса, он переходит на стадию NET_STADY_GAME и это означает начало игры.

Функция bool GNetStadyGame::OnStart(NET_STADY previous, void* init) тут же вызывает virtual void GGame::OnLaunchNetGame(), а это и есть запуск. Всё. Дальше начинается игра.

Сетевая игра

Стадия NET_STADY_GAME контролирует весь игровой процесс через функцию void GNetStadyGame::OnUpdate(). Практически, эта стадия отправляет команды, которые игрок ввел с помощью мыши и клавиатуры за определенный период времени. Также эта стадия ожидает точно таких же данных от других игроков.

Команды пользователя передаются через сообщение MESSAGE_TYPE_PLAYER_GAME. У стадии GNetStadyGame имеются поля:

int k_player;
PLAYER_MESSAGE* m_player;

которые служат для приема команд от других игроков. В структуре PLAYER_MESSAGE имеется буфер для приема сообщений NET_BUFFER next_message; однако его предназначение совсем не такое, каким оно может показаться на первый взгляд. Дело в том, что существует понятие номер сетевого такта — это такая переменная которая отсчитывает такты увеличиваясь от 0 до бесконечности, пока игра не закончится. Сетевой такт увеличивается только в том случае, если сетевой клиент получил команды от всех игроков за текущий сетевой такт. Иначе происходит ожидание, и игра замирает. Но так как обычно качество связи позволяет доставлять сообщения достаточно стабильно, то эти задержки происходят незаметно для игрока.

Если клиент всё же начинает длительно ждать, то он сообщает об этом игре через функцию virtual void GGame::OnWaitingPlayers(unsigned int dtime, int k_player_id, unsigned int* m_player_id) и задача игры уже вывести этот список игроков на экран. Время ожидания ограниченно и клиент следит, чтобы ожидание не было бесконечным. Когда исчерпывается лимит времени, то клиент начинает отсекать проблемных игроков, объявляя их проигравшими, что сразу же приводит к продолжению игры либо её завершению по причине победы.

Если клиенту получил от другого игрока команды за текущий такт, то они сразу передаются игре с помощью функции virtual void GGame::SetPlayerNetMessage(unsigned int sender, MEM_DATA& message). Но если полученное сообщение сразу передается в игру, то наверное не очень понятно, зачем нужен еще один буфер NET_BUFFER next_message? А вот зачем.

Как я уже говорил, сетевое взаимодействие сильно напоминает многопоточность, когда выполнение каких-то действий может откладываться на неопределенный срок по причине синхронизации потоков. Так вот… в сетевой игре запросто может образоваться ситуация, когда один компьютер стал обгонять другой на 1 сетевой такт. В этом случае пришедшее от обгоняющего компьютера сообщение может быть помечено, как сообщение за следующий такт, до которого наш компьютер еще не добрался. И тогда наш компьютер должен сделать следующее… он просто сохранит это сообщение в свой собственный буфер NET_BUFFER next_message, и пока временно не будет больше выполнять никаких действий по этому поводу, но он будет знать, что сообщение за следующий сетевой такт от такого-то игрока уже получено. И когда начнется этот следующий сетевой такт, то первым делом наш компьютер возмёт эти команды из собственного буфера и тут же передаст их в игру через virtual void GGame::SetPlayerNetMessage(unsigned int sender, MEM_DATA& message). Это очень важный момент который желательно понимать для построения сетевого взаимодействия в аналогичных играх.
Также нужно понимать, что на 2 такта обгон уже невозможен, так как сработает процесс "ожидание других игроков", поэтому максимальное опережение может быть только на 1 сетевой такт.

Но чтобы принять команды, нужно для начала, чтобы кто-то их отправил. Сетевой клиент не должен знать ничего о специфики команд, используемых в игре, поэтому он просто вызывает функцию virtual MEM_DATA GGame::GetPlayerNetMessage(), которая возвращает ему готовый буфер с командами в виде MEM_DATA. Полученный буфер отправляется одновременно и себе всем другим игрокам игрокам.

MEM_DATA message=owner->game->GetPlayerNetMessage();

GNetSocket* socket=owner->GetMainSocket();
owner->game->SetPlayerNetMessage(socket->player_id, message); // копируем сообщение в собственный массив, как будто оно пришло по сети

int k_receiver=owner->RefreshReceiverList();
owner->m_receiver[k_receiver]=takt; // добавляем в сообщение номер сетевого такта, к которому относится сообщение
NET_BUFFER_INDEX* result=owner->PrepareMessageForPlayers(MESSAGE_TYPE_PLAYER_GAME, message.length, message.data, k_receiver, owner->m_receiver, 1);
socket->SendMessage(result); // отправляем сообщение с командами всем другим игрокам

Сетевой такт контролирует игра, и она возвращает его клиенту через функцию virtual int GGame::GetNetTakt(). Однако клиент контролирует завершение сетевого такта — это момент, когда получены все команды от всех игроков. Клиент сразу сообщает об этом игре, вызывая virtual bool GGame::OnNextNetTakt(). Эта функция, в моем случае, проверяет на рассинхронизацию сети и возвращает true, если всё в порядке. Если вернется false, то сетевой клиент автоматически начнет процесс исправления рассинхронизации, для чего хост выполнит запись всех данных в файл и передаст этот файл всем другим игрокам, а другие игроки прочитают это файл и продолжат игру с этими полученными данными. Практически, хост сделает Save, а остальные игроки — Load. Контроль за рассинхронизацией сети я выполняю через подсчет суммы случайных чисел, выработанных за один сетевой такт. Случайные числа вырабатываются постоянно и на всех компьютерах они должны быть одинаковы, иначе это признак того, что сеть расинхронизировалась.

Если virtual bool GGame::OnNextNetTakt() возвращает true, то клиент для себя отмечает этот факт в переменной on_next_net_takt — это для него означает, что сетевой такт завершен. Игра же должна в своем основном цикле периодически вызывать функцию клиента bool GNetClient::IsNextNetTakt(){return on_next_net_takt;} и когда возвращается true, игра увеличивает сетевой такт на 1 и выполняет все команды, полученные по сети за прошлый сетевой такт, для каждого игрока. Затем массивы команд очищаются и всё начинается по новой, но уже с увеличенным значением сетевого такта.

Команды, которые игрок вводит через мышь и клавиатуру, попадают в сеть далеко не сразу. На деле происходит так, что в текущий сетевой такт в сеть передаются команды, которые были собраны за прошлый сетевой такт, а в это время собираются новые команды от мыши и клавиатуры. Т.е. происходит задержка в реакции на 1 сетевой такт. Это задержка называется Латентностью сети. Однако нет никакого смысла делать так, чтобы сетевые такты совпадали с тактами игры. Например, если игра обновляется 60 раз в секунду, то вполне можно сделать так, чтобы одному сетевому такту соответствовало 10 игровых. Вряд ли пользователя будет сильно раздражать задержка реакции в 1/6 секунды.

Шифрование трафика

Должен признать, что я не силен в теме защиты данных и хочу напоследок лишь обратить на неё внимание. К серверу не обязательно подключаться из игры, как на это расчитывает разработчик, т.е. практически, подключится можно с любой программы, обладающей возможностями для установки соединения с произвольным IP-адресом и портом. Далее можно начать отправлять на сервер всё что угодно. Сервер должен стараться как минимум проверять сообщения не корректность, иначе он просто схлопнется, получив порцию «бреда» и запутавшись в ней.
Также обычно сообщения с обоих сторон подвергаются шифровке.

Я не буду особо рассуждать на эту тему, скажу лишь, что минимальное шифрование трафика я использовал и у себя. Не думаю, что мою защиту сложно взломать, так как на данном этапе у меня нет ни желания, ни сил этим заниматься. Надеюсь, что пока меня будет защищать малая известность моего проекта, а там видно будет…

Об одной ошибке, которая существует в Windows уже очень давно

В первой части статьи я описал сетевую игру в RTS только в виде общей идеи. Но там я указал на самую большую проблему, которую таит в себе сеть в RTS — необходимость иметь полностью одинаковые вычисления на всех компьютерах. Если какой-то компьютер начнет делать хоть что-то чуть-чуть не так, то через пару минут у вас на разных компьютерах всё происходящее будет очень сильно различаться. А игра просто войдет в ступор, когда какой-то игрок попробует управлять на своем компьютере юнитом, который на другом компьютере уже давно «погиб в перестрелке». Подобные ошибки я считаю самыми страшными из тех, которые я видел, так как ловить такие баги логикой, практически, невозможно. Причины таких ошибок обычно какая-нибудь несущественная мелочь типа «забыл заново проинициализировать переменную при повторном запуске сетевой игры». И в результате эта переменная гарантированно развалит синхронизацию сети, причем очень задолго до того, как сама игра наконец схлопнется.

Если кому интересны мои рассуждения на эту тему, то обратитесь к первой части статьи. Сейчас же я хочу рассказать об одном, на мой взгляд, очень пакостном глюке, который присутсвует в Windows с незапамятных времен. Он гарантированно плодит ошибки в вычислениях с плавающей точкой, которые в свою очередь когда-то убивали мне сеть.
Обнаружил я эту проблему предположительно в 2003-2004 году еще на Windows 98, оттуда она благополучно перекочевала в Windows XP, а недавно я обнаружил, что и в Windows 8 ничего не изменилось.

Основная суть ошибки в том, что соответствующие функции Windows меняют (и не возвращают назад) контрольное слово FPU. И, естественно, про такое их поведение нигде в документуции не упоминается.

Вот мой старый код, который доказывает существование проблемы на Windows XP. На Windows 8 я его не пробовал, но и на Windows 8 я тоже «влетел в ситуацию», когда моя неплохо отлаженная сетевая игра начала вдруг работать безобразно без видимых причин. Оказалось, что я случайно убрал кусочек кода, который компенсирует эту проблему.

Итак пример функции:

int Error1()
{
    double step=66.666664123535156;
    double start_position_interpolation=0;
    double position_interpolation=199.99998474121094;
    double vdiscret=(position_interpolation-start_position_interpolation)/step;
    int discret=(int)vdiscret;
    return discret;
}

Числа, конечно, странные, но зато на них проявляется ошибка.

Если вызвать функцию Error1() и пройтись по ней отладчиком, то в результате в переменную discret попадет число 2. А теперь сделаем так:

int result=Error1(); // result=2

ok=direct_3d->CreateDevice( D3DADAPTER_DEFAULT, D3DDEVTYPE_HAL, hwnd,
                                      D3DCREATE_HARDWARE_VERTEXPROCESSING,
                                      &d3d9pp, device_3d );

result=Error1(); // а теперь уже result=3

Т.е. если между вызовами функции Error1() вклинить функцию DirectX-а, которая создает устройство DirectX, то второй вызов функции даст неожиданный результат discret=3. Так получается из-за того, что в первый раз vdiscret будет равен 2.99999......., а во второй раз уже 3. Разница на деле минимальна, но так как у меня в игре применяются переменные типа double, то этого вполне достаточно, чтобы убить всю сетевую игру. И кроме того, без знания причин там и починить-то ничего невозможно, ведь формально сам код верный, просто где-то какой-то флаг состояния процессора не вернулся в нужное значение.

В Windows 98 эта проблема проявлялась еще яростнее, чем в Windows XP. Там этот глюк возникал просто при попытка перечислить имеющиеся разрешения монитора, причем с помощью WinAPI, т.е. даже без DirectX. В Windows 8 я не исследовал этот вопрос, так как я уже заранее знал, что с этим делать.

Мне известны 2 решения, которые «лечат» эту напасть. Когда-то я просто создавал отдельный поток, переключал в нем разрешение экрана и потом просто убивал поток вместе с ошибкой. На основной поток эта проблема в данном случае не влияла.

Второй способ более прост.

unsigned int status=_controlfp(0,0);

// Переключаем разрешение экрана
// ...
// ...
// ...

_controlfp(status,_MCW_DN | _MCW_IC | _MCW_RC | _MCW_PC);

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

Текущее состояние игры или релиз

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

Недавно я, с большим удивлением обнаружил, что помимо всем известных доменов типа com, org, net и т.д, на свете существует и домен land. А так как моя игра на английском называется Onimod land, то я незамедлительно занял под игру домен onimod.land, так что теперь у игры, как и когда-то в прошлом, есть свой персональный сайт — http://onimod.land

До выхода на Steam-е, думаю, дело дойдет чуть позже, а пока я выпускаю игру через свой сайт. Желающие поддержать мой проект материально, смогут это сделать на сайте с игрой. Однако я сам живу в России и понимаю, что у людей здесь имеются куда более насущные статьи расходов, чем покупка софта. Поэтому, если игра вам понравилась, а поддержать меня материально у вас нет финансовой возможности, то можете попросить у меня ключ бесплатно, используя форму обратной связи на сайте. Просьба не лениться представляться, а то письма типа «дай ключ» от wertwq@mail.ru вызывают у меня в основном негативные эмоции.

Игра стала коммерческой и, вероятно, скоро я узнаю, нужна она кому-то кроме меня или нет.

Пожалуй, на этой филосовской ноте я и завершу своё повествование. Благодарю всех за недюжую силу воли, проявленную при чтении данной статьи, а также за снисходительное отношение к моему «литературному дарованию».

С уважением,
Алексей Седов (он же Odin_KG)

P.S.
У меня есть желание перевести статьи об игре на английский, по крайней мере, первую часть и алгоритм поиска пути.
Если у кого-то есть хорошие знания английского языка, хоть какое-то понимание того, о чем я пытаюсь говорить, и желание помочь мне в этом деле, то я буду очень рад. Я пробовал как-то нанять переводчика, но он мне такую грамматически правильную смысловую ахинею выдал, что делать еще одну аналогичную попытку у меня особого желания нет.
Эту просьбу я удалю из статьи, если кто-то откликнется и реально возьмется за дело.

Автор: Odin_KG

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js