Разработка лаунчера для MMORPG Project Genom, часть 1
Юрий “yurembo” Язев
независимый игродел
Лаунчер
В данном случае, в рамках MMORPG,
лаунчер - это программа, которую пользователь скачивает с сайта определенной
игры и запускает на своем компе. Она, в свою очередь, скачивает весь клиент
данной игры на компьютер пользователя. В случае обрыва соединения, лаунчер
докачивает нужный контент. Во время последующих запусков, если лаунчер при
сверке файлов игры, находящих на клиенте и сервере, обнаруживает обновления на
сервере, тогда он докачивает измененные или добавленные файлы. Вот, собственно,
все основные задачи лаунчера.
Сегодня, на
примере разработки лаунчера для MMORPG Project Genom я расскажу, как я решил эти и другие возникшие в процессе
разработки задачи.
Платформа
и инструментарий
Давным-давно,
Геном разрабатывался на движке Torque
3D… Время шло, движки
умирали и появлялись. Нынешняя итерация Генома зиждется на движке Unreal Engine 4. Тем не менее, в
сегодняшнем разговоре нам от этого ни холодно, ни жарко. Игра ориентирована на
платформу Windows NT, следовательно,
лаунчер, в идеале, должен поддерживать все версии этой операционки, начиная от Windows XP. При этом для нас, в
данном случае, неважно будет ли работать игра на этом парке систем. Ибо для нас
важен лаунчер.
Итак, чем я
руководствовался при выборе языка и инструментария. Конечно, наиболее
предпочтительный вариант – это воспользоваться языком C# и WinForms. C#
позволяет не беспокоиться о куче, а WinForms включает широкий ассортимент
визуальных компонентов. Между тем, чтобы все это богатство работало, на компе
конечного пользователя должен быть установлен .NET. По сути, это не проблема, так как
начина с Windows XP SP
3, .NET третьей версии пред
устанавливается. Однако код, в таком случае, по определению не нативный.
Немного
подумав, взвесив все «за» и «против», я решил выбрать нативный вариант, чтобы
мое приложение наверняка работало на всех Windows-системах, включая Windows 2000 и 9x.
В таком случае, мое решение остановилось на старой, заросшей мхом, посыпанной
пеплом тяжелых боев библиотеке MFC.
MFC все
еще жива?
Когда-то очень
давно, будучи очень молодым, я купил книгу «Программирование на Visual C++ 6.0» 5-е издание серии
«Для профессионалов» издательства Питер. Уже тогда авторы задавались вопросом «MFC, ATL, WFC – MFC мертва?» Тем не менее, MFC жива и по сей день: тому подтверждение
компании, которые построили свой бизнес на разработке MFC-компонентов! MFC-элементы и по сей день прекрасно
служат для построения пользовательских интерфейсов, как в унаследованных, так и
в новых приложениях. С каждой новой версией Visual Studio так же обновляется MFC, Microsoft добавляет
в нее новые элементы, оптимизирует и подгоняет под новые версии операционной
системы старые (унаследованные) компоненты.
Проектирование
Описанное в начале
тех. задание на разработку лаунчера очень поверхностное. Но, и в начале
разработки ничего более подробного у меня не было. Будем разрабатывать по
шагам: сделав один, будем смотреть, чего не хватает, и шагать дальше.
Инструменты и
средства разработки уже выбраны, теперь, надо перейти к определению списка задач.
Для удобства пронумеруем все шаги. Сначала, рассмотрим пожелания к
пользовательскому интерфейсу.
1)
Лаунчер должен представлять оконное Win32-приложение.
2)
Фоновая картинка должна растягиваться на всю
область окна определенных размеров.
3)
Окно появляется в центре экрана.
4)
При запуске приложения, оно должно скачать с
сервера изображение, отображаемое в левой части окна, и текст – описание,
отображаемое в правой части окна.
5)
Кнопки свертывания и закрытия окна должны быть
кастомными.
6)
Перетаскивание окна должно осуществляться за
верхнюю панель (заголовок) окна.
7)
При первом запуске лаунчера, он должен
предложить пользователю выбрать папку, куда будет закачан клиент.
8)
Диалог выбора папки должен отобразиться по
нажатию на кнопку «Изменить».
9)
Путь к выбранному каталогу должен отображать в
окне лаунчера.
10)
Скачивание клиента должно начаться после нажатия
на кнопку «Установить».
11)
Перед началом скачивания внизу окна должны
присутствовать 2 надписи:
1)
Требуется места: (размер клиента в МБ)
2)
Свободное место: (оставшееся свободное место в
МБ на выбранном диске)
12)
После запуска программы, она подключается к
жестко заданному серверу. Этот сервер может быть, как FTP, так и HTTP. В процессе разработки мы
рассмотрим плюсы и минусы каждого, а пока будет иметь в виду, что у нас есть
выбор. FTP-сервер может
(и должен) пускать в свои владения только авторизированного пользователя; у HTTP-сервера есть открытая
для чтения директория, где, обычно, хранится веб-сайт, к которому имеет доступ
любой пользователь сети. В эту же директорию мы можем положить данные для
скачивания – игровой клиент.
13)
Нам предстоит выбрать механизм и/или фреймворк
для подключения и сетевого взаимодействия.
14)
После подключения и/или успешной авторизации, из
определенной подпапки лаунчер должен сравнить все файлы, имеющиеся на компе
юзера и находящиеся на сервере. В случае, отсутствия каких-то либо файлов лаунчер
скачивает их.
15)
В случае различия версий файлов, лаунчер заменяет
на компе пользователя эти файлы. Условная версия файла может вычислять
следующими способами: различие в размерах файлов с одинаковыми именами и
относительными расположениями, даты модификации, контрольные суммы. Во время
разработки мы выберем подходящий вариант.
16)
В случае неудачного скачивания какого-либо файл,
его закачка должна повториться. Успешность скачивания файла определяется по тем
же параметрам, что условная версия файла.
17)
После успешного скачивания всех файлов, лаунчер
должен предоставить способ запуска клиента игры.
18)
Прогресс скачивания файлов должен отображаться
на шкале, в процентах показывающей количественное соотношение скачанных данных
к общему их количеству.
Создание
списка файлов
Будем разбирать
наш список не по порядку. Оставим внешний вид на потом. Разберемся с начала с
другим насущным вопросом – с файлами. Поэтому начнем с пункта №14. Список
файлов. В первом приближение, список не нужен, лаунчер может воспользоваться
функциями из библиотеки WinInet для обхода всех файлов в заданном каталоге на удаленном
сервере: после установки соединения, с помощью функции FtpFindFirstFile
получить список всех файлов и подпапок, а затем, с помощью функции InternetFindNextFile
переходить к следующему файлу и читать его свойства. Таким образом, мы
просмотрим все имеющиеся в заданном каталоге файлы. Если эту операцию проведет
один удаленный пользователь, то все ОК, однако, у нас же сервер сетевой игры, и
к нему может одновременно подключиться
сотни игроков, желающие скачать или запустить игру. В обоих случаях нам нужно
сверить все файлы. Тогда, серверу надо будет осуществить столько проверок всех
файлов, сколько пользователей подключилось к игровому серверу. Это вызовет
замедление работы сервера, а, возможно, станет причиной отказа в обслуживании.
Поэтому, очевидно, что этот вариант не подходит для высоконагруженных игровых
серверов.
Вот, поэтому
нам нужен заранее приготовленный список файлов. Кроме имен файлов список должен
содержать сведения сравнения для каждого файла. Во время подключения клиента к
серверу, лаунчер запрашивает с сервера этот файл, и, так как последний содержит
самые новые сведения, лаунчер по этому файлу может проверить свои файлы. Если
какого-то файла он у себя не нашел, то докачивает его с сервера, а, если
обнаружил расхождение в значениях свойств, то заменяет у себя на скаченный с
сервера.
В итоге, нам
нужна программа, которая будет сканировать определенный каталог на сервере (где
находится клиент игры для скачивания), включая подкаталоги, и собирать
информацию обо всех файлах. Она будет выполняться локально, так как её будет
запускать администратор сервера. В этом случае, подготавливать новый файл –
запускать приложение надо будет только при обновлении файлов клиента на
сервере.
Разработка
приложения FileData
Задача
поставлена. Я назвал это приложение FileData, к лаунчеру оно имеет второстепенное отношение,
поскольку оно предназначено для выполнения на сервере. Для упрощения задачи,
это консольное приложение, так как никакие параметры для его работы настраивать
не надо, в том числе, не нужно передавать никакие параметры командной строки.
Для разработки
приложения я воспользовался Visual Studio
2013 Community Update
5. Для начала я создал обычное консольное приложение. Начнем с функции main(), с которой, как
считается, начинается выполнение программы. Конечно, это не так, прежде чем
заработает функция main()
выполняется куча кода, заботливо вставленного компилятором, для выделения
памяти, загрузки системных функций и т.д., но мы в данном случае притворимся
желторотыми прикладными программистами и будем считать начало с main(). Итак, в функции main() создаются 3
переменные: для файлового вывода (ofstream fout), для записи количества файлов
(int fc) и для подсчета размера всех файлов (DWORDLONG filesSize). После этого
вызывается функция fileSearch,
которая принимает 4 параметра: маску для поиска файлов (так как, нам нужны все
файлы, то маска представляет собой “*”) и все 3 выше созданные переменные.
Забегая вперед, они передаются по ссылке, поэтому после выполнения функции fileSearch в
них содержатся значения соответственно, количество и размер всех файлов, эти
значения выводятся в консоль и записываются в файл. В конце открытый для записи
файл закрывается.
void main()
{
ofstream fout(_fileName,
ios::trunc);
if (!fout) return;
int fc = 0;//files count
DWORDLONG filesSize = 0;
fileSearch(LPCWSTR("*"),
fc, filesSize, fout);
cout << "Files_count:
" << fc << " size " << filesSize <<
endl;
fout << "Files_count:
" << fc << " size " << filesSize <<
endl;
fout.close();
system("PAUSE");
}
Перейдем к разбору
функции fileSearch. В
ней выполняется основная задача нашей утилиты: поиск файлов.
void fileSearch(LPCWSTR dir, int& fc, DWORDLONG& filesSize, ofstream& fout)
{
WIN32_FIND_DATA FindFileData;
HANDLE hf;
hf = FindFirstFile(dir, &FindFileData);
if (hf != INVALID_HANDLE_VALUE)
{
do
{
string fileName = narrow(FindFileData.cFileName);
if ((fileName != ".") && (fileName != "..") && (fileName != _fileName) && (fileName != _programName)) {
string d = narrow(dir);
d = deleteAsterix(d);
string filePath = d + fileName + "\\*";
if (FindFileData.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) {
wstring stemp = wstring(filePath.begin(), filePath.end());
LPCWSTR sw = stemp.c_str();
fileSearch(sw, fc, filesSize, fout);
}
else {
// использование структуры ULARGE_INTEGER
//для нахождения 64-х битных значений, состоящих из двух 32-х
//битных частей
ULARGE_INTEGER ul;
ul.HighPart = FindFileData.nFileSizeHigh;
ul.LowPart = FindFileData.nFileSizeLow;
ULONGLONG fileSize = ul.QuadPart;
filePath.erase(filePath.size() - 2, 2);
UINT32 crc = calcCtrlSum(filePath);
fout << "\"" << filePath << "\"" << " " << fileSize << " " << crc << endl;
cout << "\"" << filePath << "\"" << " " << fileSize << " " << crc << endl;
fc++;
filesSize += fileSize;
}
}
} while (FindNextFile(hf, &FindFileData) != 0);
FindClose(hf);
}
}
Программа начинает
сканировать каталоги с того, где находится исполняемый файл, зарываясь вглубь
файловой системы. Аргументы, принимаемые функцией fileSearch, мы уже рассмотрели, перейдем
внутрь. Вначале, объявляются переменные типа WIN32_FIND_DATA (WIN32_FIND_DATA FindFileData)
и HANDLE (HANDLE hf). Первый представляет
собой структуру, содержащую информацию по найденному с помощью функции FindFirstFile файлу. А, второй – дескриптор файла. Далее, вызываем функцию
FindFirstFile, чтобы
найти заданный файл. Функция принимает имя файла, который надо найти, так как
нам надо найти все файлы, а первый найденный файл может быть любым, мы передаем
символ астериск (*). Вторым параметром функция по ссылке принимает переменную
типа WIN32_FIND_DATA, в которой возвращает сведения о найденном файле.
Вдобавок, функция возвращает дескриптор найденного файла. В случае если ничего
не найдено функция возвращает константу INVALID_HANDLE_VALUE. В зависимости от
возвращенного значения мы определяем: продолжать выполнение функции или нет. И,
если ничего не найдено – вернулось значение INVALID_HANDLE_VALUE, тогда
продолжать выполнять функцию не имеет смысла. В ином случае, мы запускаем цикл.
Сначала, нам надо взять имя найденного файла. Оно хранится в структуре WIN32_FIND_DATA
в поле cFileName:
string fileName = narrow(FindFileData.cFileName);
Имя файла в
структуре WIN32_FIND_DATA хранится в формате wstring (широкая строка), поэтому перед ее присвоением переменной
типа string, ее надо
сузить – преобразовать. Эта операция осуществляется в функции narrow:
string narrow(wstring const& text)
{
locale const loc("");
wchar_t const* from =
text.c_str();
size_t const len = text.size();
vector<char> buffer(len +
1);
use_facet<ctype<wchar_t>
>(loc).narrow(from, from + len, '_', &buffer[0]);
string s =
string(&buffer[0], &buffer[len]);;
return s;
}
Когда имя
получено, мы проверяем, чтобы оно не было равно: «.» - скрытый файл – ссылка на
текущий каталог, «..» - ссылка на каталог верхнего уровня, имя файла вывода (info.txt), имя исполняемого файла программы (FileData.exe). Если имя файла прошло такую
проверку, то имя директории, переданное в параметре, сужается (посредством
функции narrow); далее,
посредством функции deleteAsterix проверяем всю полученную строку и удаляем из нее все символы
*, которые не могут присутствовать в имени файла. Функция deleteAsterix выглядит следующим
образом:
string deleteAsterix(string s)
{
string::iterator it = s.begin();
while (it != s.end()) {
if (*it == '*')
it
= s.erase(it);
else
it++;
}
return
s;
}
Затем, строим
новую строку и присваиваем ее переменной filePath:
string filePath = d + fileName +
"\\*";
То есть, filePath содержит:
имя материнского каталога + ‘\’ + имя текущего файла/каталога + ‘\*’.
После этого
проверяем, что найденный функцией FindFirstFile объект является каталогом:
if (FindFileData.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) {
Для этого
смотрим: присутствует ли в атрибутах объекта элемент FILE_ATTRIBUTE_DIRECTORY,
если ответ положительный, значит, объект – каталог. В таком случае, значение filePath есть путь к
вложенному каталогу, и мы его расширяем до wstring, затем конвертируем в тип – указатель на строку широких
символов:
wstring stemp = wstring(filePath.begin(), filePath.end());
LPCWSTR sw = stemp.c_str();
Когда путь к
вложенному каталогу будет в нужном формате, мы передаем его вместе с полученными
аргументами рекурсивно вызываемой функции fileSearch, которая выполнит такой же поиск во вложенном
каталоге, а, найдя подкаталоги, так же войдет в каждый из них:
fileSearch(sw, fc, filesSize, fout);
В том случае,
если найденный с помощью функции FindFirstFile объект является файлом, тогда из
заполненной структуры WIN32_FIND_DATA надо выбрать интересующие нас свойства
файла.
Первым делом,
нас интересует размер файла. Так как, размер файла может превышать максимальное
значение, помещаемое в 32-х разрядные регистры, значение размера хранится в 2-х
полях структуры WIN32_FIND_DATA: nFileSizeLow и nFileSizeHigh. 32-х битные системы уже давно сошли со
сцены, уступив 64-х разрядным. Однако остался вот такой рудимент. Но мы можем
из двух 32-битных частей собрать одно 64-х битное. Для этого можно
воспользоваться объединением ULARGE_INTEGER, основанном на структуре, состоящей
из двух полей:
DWORD LowPart;
- содержит младшую часть числа;
DWORD HighPart;
- содержит старшую часть числа;
Присвоив этим
полям 32-х битные значения младшей и старшей частей числа, из поля QuadPart можно
получить 64-битное число.
ULARGE_INTEGER ul;
ul.HighPart = FindFileData.nFileSizeHigh;
ul.LowPart = FindFileData.nFileSizeLow;
ULONGLONG fileSize = ul.QuadPart;
Тип ULONGLONG представляет
64-битное число.
Таким образом,
мы получили размер файла, какой бы большой он не был.
Как мы помним,
в переменной filePath содержится относительный путь к файлу + его имя + ‘\*’.
Очевидно, для дальнейшей работы с файлом нам надо избавиться от последних двух
символов:
filePath.erase(filePath.size() - 2, 2);
Контрольная
сумма
Нужен еще один
атрибут файла, по которому можно было бы сделать проверку или сравнить. Сначала
я реализовал этот атрибут в виде последней модификации файла. Но целостность
файла, таким образом, не проверялась. Тогда, я решил сделать проверку
контрольных сумм. При этом текстовые файлы, в отличие от двоичных, не поддаются
такой проверке. Поэтому, для них проверка осуществляется только по размеру. Мы
еще вернемся к этому вопросу. Вычисление контрольных сумм для небольших файлов
проходит очень быстро, но если файл большой по размеру, тогда контрольная сумма
для него вычисляется очень долго. Поскольку, программе надо считать файл
целиком в оперативную и/или виртуальную память, а это при больших файлах
занимает длительное время. Тогда, я решил читать не весь файл, а только его
часть, а именно 4194304 бита или 4 мегабайта. По моим вычислениям и тестам это
достаточное число для двоичных файлов, а для текстовых, как я сказал выше, сравнение
по контрольным суммам не подходит.
Для подсчета
контрольных сумм я решил воспользоваться средствами библиотеки Boost. Поскольку, они очень хорошо
оптимизированы. Итак, объявляем переменную типа boost::crc_32_type для работы с
контрольной суммой, открываем бинарный файл для чтения, читаем в подготовленный
буфер размером 4 мб данные, затем, методу process_bytes объекта типа crc_32_type передаем считанный буфер и количество
считанных символов. В результате, метод на основе переданных данных вычисляет
контрольную сумму и сохраняет ее внутри объекта. Чтобы получить ее, достаточно вызвать
метод checksum(). Контрольная сумма представляет собой целочисленное без
знаковое 32-х битное значение – UINT32.
UINT32 calcCtrlSum(string filePath)
{
//вычисление
контрольной суммы
boost::crc_32_type crc_sum;
ifstream ifs(filePath,
ios_base::binary);
if (ifs.is_open()) {
char *buffer =
new char[buffer_size];
ifs.read(buffer,
buffer_size);
crc_sum.process_bytes(buffer,
ifs.gcount());
delete[] buffer;
}
//***
UINT32 crc = crc_sum.checksum();
return crc;
}
После получения
контрольной суммы для определенного файла, у нас есть весь набор необходимых сведений
о файле, поэтому их можно записать в файл. Мы передали в функцию fileSearch поток
файлового вывода, теперь можем воспользоваться им:
fout << "\"" << filePath <<
"\"" << "
" << fileSize << " " << crc << endl;
Те же данные
выведем в консоль:
cout << "\"" << filePath <<
"\"" << "
" << fileSize << " " << crc << endl;
Увеличим счетчик
файлов и суммарный объем на размер текущего файла:
fc++;
filesSize += fileSize;
После тела цикла
переходим к следующему файлу, в случае его отсутствия прекращаем выполнение:
} while (FindNextFile(hf, &FindFileData)
!= 0);
В конце, закрываем
дескриптор файлового поиска.
Тем самым, функция
соберет информацию обо всех файлах, находящихся в текущем с исполняемым файлом программы
каталоге, а так же во всех вложенных папках и сохранит эти сведения в файл.