Сетевые события & сообщения
Обзор
Большую часть сетевой пропускной способности между сервером и клиентом занимает обнобления энтитей (сжатые моментальные снимки). Всякий раз когда длительное и реалистичное состояние должно быть передано по сети, оно должно кодироваться как состояние энтити, а не отправляться как сообщение или событие. Движок Half-Life 1 (GoldSrc) выполняет множество обновлений состояний с помощью пользовательских сообщений, что создает некоторые проблемы которые случаются когда теряются пакеты и сообщения доставляются слишком поздно или теряют синхронизацию с изменениями энтитей. Также рассылка HLTV и демо имеет проблемы с сообщениями и событиями так как они могут проигрывать их повторно, но не восстанавливают их воздействие во время прыжка назад во времени. Этот процесс намного проще реализуется если энтити могут быть возвращены к любому предыдущему состоянию. Но есть ситуации когда отправка сообщений о событиях имеет преимущества.
Движок Source использует сообщения в основном для обновления HUD и информации на экране на подобии сообщений чата или коротких визуальных эффектов которые не оказывают большого влияния. Движок Source использует три разлные системы сообщений: игровые сообщения, сообщения пользователя и сообщения энтитей. Игровые сообщения генерируются когда возникнет общее, глобальное сообщение которое может быть интересно всем игрокам или другим подсистемам (система ведения логов, игровые анализоторы и т.д.). Сообщения пользователя похожи на сообщения Half-Life 1 и используются для передачи специальной информации определенным клиентам. Сообщения энтитей являются неотложными сообщениями широковещательно рассылаемые энтитями для оповещения неотложных изменений состояний. В основном сообщения энтитей не используются так часто поскольку нормальное сообщение энтитей позволяет простую и неотложную передачу, поэтому они здесь не обсуждаются.
Позади событий и сообщений находится две другие сетевые системы для передачи данных клиентам. Во-первых, используются временные энтити для создания кратко живущих, не объемных энтитей, которые не синхронизируются или не обновляются после их инициализации. Эти временные энтити в основном используются для визуализации в игровых спецэффектах подобно взрывам или пулевых ударов. Сообщения временных энтитей ненадежные и теряются если создается слишком много временных энтитей одновременно (максимально 32 за обновление клиента в многопользовательской и 255 в однопользовательской).
Последняя система, которая будет затронута, - это контейнер таблицы строк сервера. Таблицы строк - это простые индексированные таблицы, которые содержат в каждой записи строку и опциональные двоичные данные (до 4кБ). Таблицы строк зеркалируются на всех клиентах и любое внесенное изменение или добавление дублируется немедленно. Таблицы строк могут использоваться для избежания постоянной повторной передачи одних и тех же строк а для отправки вместо этого только индексов соответствующих этим строкам.
Игровые события
Игровые сообытия являются базовыми событиями, связанными с геймплеем, которые описываются в ресурсных файлах. Моды могут расширять существующие игровые сообытия или определять новые события. Центральным объектов сервера и клиента, контролирующим и доставляющим игровые сообытия, является игровой менеджер сообытий. Этот менеджер загружает события, регистрирует слушателей сообытий, генерирует и доставляет их зарегистрированным слушателями событий (смотрите интерфейс IGameEventManager
). Обработчики событий могут быть локальными объектами сервера (слушателями событий серверной стороны) или удаленными слушателями событий на клиенте. Менеджер событий делает сериализацию событий для отправки их через сетевое соединение и обрабатывающих их на клиенте. Перед использованием игровых событий они должны быть определены в ресурсном файле игровых событий и загружен менеджером игровых событий на сервере и клиенте. Игровое события имеет уникальное имя и количество полей данных или полей данных где каждая запись может быть вещественным, целым числом или строкой. Простейшие игровые события описаны в resources\gameevents.res. Например, ниже приводится определение для игрового события "player_death", генерируемого когда игрок умирает:
"player_death" // игровое событие, уникальное имя может иметь длинну 32 символа { "userid" "short" // ID пользователя который умер "attacker" "short" // ID кто убил }
Доступные типы для полей таблиц:
string | строка завершаящаяся нулевым символом |
bool | беззнаковое целое, 1 бит |
byte | беззнаковое целое, 8 бит |
short | знаковое целое, 16 бит |
long | знаковое целое, 32 бита |
float | вещественное, 32 бита |
Менеджер событий должен загрузить ресурсные файлы всех игровых событий перед тем как будет использоваться любое игровое событие:
gameeventmanager->LoadEventsFromFile("resource/gameevents.res");
После чего сервер может генерировать эти события, которые храняться как объекты KeyValues
. Класс KeyValues
предоставляет простой механизм чтения и записи его полей с данными:
IGameEvent* pEvent = gameeventmanager->CreateEvent("player_death");
pEvent->SetInt("userid", pVictim->GetUserID());
pEvent->SetInt("attacker", pScorer->GetUserID());
gameeventmanager->FireEvent(pEvent);
После генерации события, выделенная память для KeyValues
высвобождается игровым менеджером событий. Менеджер событий производит сериализацию данных используя данную информацию о типе из ресурсного файла и распределяет события всем клиентам. На стороне клиента объект слушатель регистрироваться как слушатель событий определенного события. Нет ограничения на количество слушателей для одного события (подобно сообщениям пользователей, которые могут устанавливать только одну функцию ответа на сигнал). Простой слушатель для события "player_death" может выглядеть ка это:
class CMyListener : public IGameEventListener2
{
CMyListener()
{
// добавляем свой слушатель на клиентской стороне для этого события
gameeventmanager->AddListener(this, "player_death", false);
}
void FireGameEvent(IGameEvent* pEvent)
{
// проверяем тип и печатаем сообщение
if (!strcmp("player_death", pEvent->GetName()))
{
Msg("Player ID %i killed player ID %i\n",
pEvent->GetInt("attacker"), pEvent->GetInt("userid"));
}
}
};
CMyListener *mylistener = new CMyListener();
Сообщения пользователя
Сообщения пользователя, подобно игровым событиям, имеют уникальные имена которые используются для их идентификации себя. Подобно менеджеру событий, сообщения пользователя контроллируются классом CUserMessages
, который регистрирует и устанавливает ответы на вызов. Но в отличие игровых событий, сообщения пользователей не сериализируются и десериализируются автоматически, это должно производится вручную во время отправки сообщения пользователя а также во время получения их на клиенте. Поэтому код сервера и клиента должны обновляться каждый раз когда изменяется пользовательское сообщение.
Сообщения пользователя регистрируются в общей функции RegisterUserMessages()
. Каждое сообщение хранит уникальное имя и сообщает системе его размер в байтах (или -1 если сообщение имеет динамический размер подобно строкам):
usermessages->Register( "MyMessage", 2 ); // отправляет только 2 байта
Для отправки сообщений пользователя код сервера должен указывать группу клиентов получателей, что делается первым делом при создании объекта CRecipientFilter
. Отправляя сообщение пользователя начинается коммандой UserMessageBegin()
сопровождающейся блоком комманд WRITE_*
для заполнения сообщения пользователя данными (смотрите enginecallback.h
для всех доступных WRITE_*
комманд). Сообщения закрываются и отправляются во время выполнения комманды MessageEnd()
:
CSingleUserRecipientFilter filter ( pBasePlayer ); // устанавливаем отправителя
filter.MakeReliable(); // делаем надёжную передачу
UserMessageBegin( filter, "MyMessage" ); // создаем сообщение
WRITE_BYTE( 4 ); // наполняем сообщение
WRITE_BYTE( 2 ); // наполняем сообщение
MessageEnd(); //отправляем сообщение
Невозможно начать отправку еще одного блока сообщения пользователя в то время как передается первое сообщение. Емкость сообщения ограничена 255 байтами, если его размер превышает объем одного блока, сообщение не отправляется и выдается предупреждение.
На стороне клиента, должна быть вызвана общая функция RegisterUserMessages()
для регистрации того же имени и размера что и на сервере. Также для каждого пользовательского сообщения должен быть установлен обработчик сообщения (ответ на вызов). Нет возможности устанавливать несколько обработчиков для одного сообщения полльзователя. Дескриптор сообщения получает сообщение как объект bf_read
(класс для чтения битовых потоков) и должна быть определена функция как показано ниже:
// объявляем обработчик пользовательского сообщения
void __MsgFunc_MyHandler( bf_read &msg )
{
int x = msg.ReadByte();
int y = msg.ReadByte();
}
// регистрируем обработчик сообщения
usermessages->HookMessage( "MyMessage", __MsgFunc_MyHandler );
Обычно используется вспомогательный макрос подобно HOOK_MESSAGE
или HOOK_HUD_MESSAGE
для установки функции обработчика (определено в hud_macros.h
).
Временные энтити
Для создания коротких визуальных эффектов, не самый лучший путь создавать и уничтожать настоящие энтити, поскольку это вызовет значительную перегрузку сети. Визуальные эффекты больше похожи на события "сделал-забыл" и если их пакет данных теряется нет необходимости его передавать заново.
Временные энтити являются энтитями клиентской стороны которые могут быть порождены кодом сервера или клиента. Они не имеют индексов или EHANDLE-ов как у нормальных энтитей. После того как сервер породил временную энтить, она не может быть изменена. В остальном, временные энтити подобны нормальным энтитям. Они все порождаются от одного и того же базового класа CBaseTempEntity
на сервере и C_BaseTempEntity
на клиенте. Они имеют уникальное имя класса, сетевые переменные-члены и определенные таблицы отправки и получения (Смотрите документацию о "Entity Networking "]). На стороне сервера присутствует синглетон-объект для каждого класса временных энтитей, который используется для создания новых временных энтитей вызовом Create(filter, delay)
.
Серверный код для объявления и генерации произвольной временной энтити "MyEffect":
class CTEMyEffect : public CBaseTempEntity
{
public:
DECLARE_CLASS( CTEMyEffect, CBaseTempEntity );
DECLARE_SERVERCLASS();
public:
CNetworkVector( m_vecPosition );
};
// Объявление таблицы отправки сервера
IMPLEMENT_SERVERCLASS_ST(CTEMyEffect, DT_TEMyEffect)
SendPropVector( SENDINFO(m_vecPosition), -1, SPROP_COORD),
END_SEND_TABLE()
// Синглетон-объект для генерации объектов TEMyEffect
static CTEMyEffect g_TEMyEffect ( "MyEffect" );
// глобальная функция для создания произвольного эффекта
void TE_MyEffect( IRecipientFilter& filter, float delay, const Vector* position )
{
// установка данных для эффекта
g_TEMyEffect.m_vecPosition = *position;
// Отправляет его по подключению
g_TEMyEffect.Create( filter, delay );
}
Код клиента для создания этой временной энтити:
class C_TEMyEffect : public C_BaseTempEntity
{
public:
DECLARE_CLASS( C_TEMyEffect, C_BaseTempEntity );
DECLARE_CLIENTCLASS();
void PostDataUpdate( DataUpdateType_t updateType )
{
// временная энтитя создана и что-то делает
Msg("Create effect at position %.1f,%.1f,%.1f\n",
m_vecPosition[0], m_vecPosition[1], m_vecPosition[2] );
}
public:
Vector m_vecPosition;
};
// определяет клиентскую таблицу отправления
IMPLEMENT_CLIENTCLASS_EVENT_DT(C_TEMyEffect, DT_TEMyEffect, CTEMyEffect)
RecvPropVector( RECVINFO(m_vecPosition)),
END_RECV_TABLE()
Временные энтити используются довольно часто, также в общем коде (один и тот же код который компилируется в server.dll и client.dll). Для общего кода требуется общий интерфейс который позволит создавать временные энтити тем же путем на сервере что и на клиенте. Этот общий интерфейс является ItempEntsSystem
который предоставляет фунции для создания любой временной энтити. Интерфейс ItempEntsSystem
имплементируется серверной стороной в классе CTempEntsSystem
и клиентской стороной в классе C_TempEntsSystem
. Для новых классов временных энтитей, этот интерфейс должен быть расширен одинаково для обоих реализаций.
Для общего кода предсказания эти классы также обрабатывают подавление эффекта для клиентов которые создали эффект в их собственном коде предсказания. Например: клиент стреляет из оружия и немедленно создает локальный, предсказуемый эффект попадания пули. Когда сервер выполняет тот же самый код оружия снова, этот эффект попадания должен отправляться всем остальным клиентам кроме того кто стрелял. Фильтрация таких временных энитией которые создаются кодом предсказания обрабатываются функцией SuppressTE()
. Для дополонительной информации читайте Weapon Prediction .
Таблицы строк
Таблицы строк это контейнеры дубирующихся данных с индексироваными записями которые содержат в кажой записи строку и опциональные двоичные пользовательские данные (до 4КБ). Таблицы строк создаются на сервере и обновляются немедленно и гарантированно на всех клиентах. Движок предоставляет интерфейс INetworkStringTableContainer
для кода сервера и клиента для управления этими таблицами строк. Для создания нового объекта таблицы строк, должны быть указаны уникальное имя таблицы и максимальное число записей (должно быть степенью 2-ки). Когда таблица строк создается, возвращается интерфейс доступа INetworkStringTable
который может быть использван для добавления новых, поиска существующих записей или изменение их пользовательского двоичного содержимого. Строка записи не может изменяться после того как была создана. Такие же интерфейсы используются клиентской стороной для поиска или доступа таблиц строк, но не могут вносить изменения.
Таблицы строк это очень простой и эффективный путь для передачи больших блоков текстовых строк (имена материалов или ресурсов и т.д.). Главная идея состоит в том чтобы сохранить траффик передавая только индексы таблицы для часто используемых строк. Поэтому одна и та же текстовая строка никогда не передается дважды. Попрежнемуl, изменения двоичных данных может вызвать обширную загрузку сети в связи с тем что они передаются как двоичные данные и не сжимаются. Если двоичные данные обновляются слишком часто, нужно принимать решение использовать вместо них объект энтитю. Серверный код:
INetworkStringTable *g_pMyStringTable = NULL;
CServerGameDLL::CreateNetworkStringTables( void )
{
g_pMyStringTable= networkstringtable->CreateStringTable( "MyStringTable", 32 );
...
}
void InitMyStringTable()
{
int data = 42; // некоторые двоичные данные
int index1 = g_pMyStringTable->AddString( "SomeString" );
int index2 = g_pMyStringTable->AddString( "SomeData", sizeof(data), &data );
...
);
Клиентский код:
INetworkStringTable *g_pMyStringTable = NULL;
void CHLClient::InstallStringTableCallback( const char *tableName )
{
if ( !strcmp(tableName, "MyStringTable") )
{
// Просмотр таблицы
g_pMyStringTable = networkstringtable->FindTable( tableName );
}
...
}
void UseMyStringTable( int index1, int index2 )
{
Msg( " %s \n", g_pMyStringTable ->GetString( index1 ) );
int data = *(int *) g_pMyStringTable ->GetStringUserData( index2, NULL);
}