Работа через сеть: события, сообщения и таблицы строк
Обзор
Большая часть пропускной способности сетевого канала между сервером и
клиентом заполнена обновлением энтитей (сжатые кадры). Всякий раз когда
длительное и реалистичное состояние должно быть передано по сети, оно
должно кодироваться как состояние энтити, это не должны быть сообщения
или события. Движок Half-Life 1 (GoldSrc) выполняет множество обновлений
состояний с помощью пользовательских сообщений, что создает некоторые
проблемы которые случаются когда теряются пакеты и сообщения
доставляются слишком поздно или теряют синхронизацию с изменениями
энтитей. Также рассылка HLTV и демо имеет проблемы с сообщениями и
событиями так как они могут проигрывать их повторно, но не
восстанавливают их воздействие во время прыжков во времени. Этот процесс
намного проще реализуется если энтити могут быть возвращены к любому из
состояний.
Но есть ситуации когда отправка сообщений подобные событиям имеют
преимущества. Движок Source использует сообщения в основном для
обновления HUD и информации на экране на подобии сообщений чата или
коротких визуальных эффектов которые не оказывают большого влияния.
Движок Source использует три разлные системы сообщений: игровые
сообщения, сообщения пользователя и сообщения энтитей. Игровые сообщения
генерируются когда возникнет общее, глобальное сообщение которое может
быть интересно всем игрокам или другим подсистемам (система ведения
логов, игровые анализоторы и т.д.). Сообщения пользователя похожи на
сообщения Half-Life 1 и используются для передачи специальной
информации определенным клиентам. Сообщения энтитей являются неотложными
сообщениями широковещательно рассылаемые энтитями для оповещения
неотложных изменений состояний. В основном сообщения энтитей не
используются так часто поскольку нормальное сообщение энтитей позволяет
простую и неотложную передачу, поэтому они здесь не обсуждаются.
Позади событий и сообщений находится две другие сетевые системы для
передачи данных клиентам. Во первых используются временные энтити для
создания кратко живущих, не объемных энтитей, которые не
синхронизируются или не обновляются после их инициализации. Эти
временные энтити в основном используются для визуализации в спецэффектах
подобно взрывам или пулевых ударов. Сообщения временных энтитей
ненадежные и теряются если создается слишком много временных энтитей
одновременно (максимально 32 за обновление клиента в
многопользовательской и 255 в однопользовательской).
Последняя система которая будет затронута это контейнер таблицы строк
сервера. Таблицы строк это простые индексированные таблицы которые
содержат в кажой записи строку и опциональные двоичные данные (до 4kB).
Таблицы строк зеркалируются на всех клиентах и любое внесенное изменение
или добавление дублируется немедленно. Таблицы строк могут
использоваться для избежания постоянной повторной передачи одних и тех
же строк а для отправки вместо этого только индексов соответствующих
этим строкам.
Игровые сообщения
Игровые сообщения являются базовыми сообщениями основанными на геймплее
которые описываются в ресурсных файлах. Моды могут расширять
существующие игровые сообщения или определять новые. Центральный объект
сервера и клиента которые контролрует и доставляет игровые сообщения
является менеджер сообщений. Этот менеджер загружает события,
регестрирует слушателей сообщений, генерирует и доставляет их
зарегистрированным слушателями событий (смотрите интерфейс
IGameEventManager). Обработчики сообщений могут быть локальными
объектами сервера (слушателями событий серверной стороны) или удаленными
слушателями событий на клиенте. Менеджер сообщений делает сериализацию
событий для отправки их через сетевое соединение и выполняющий их на
клиенте.
Перед использованием игровых сообщений они должны быть определены в
ресурсном файле игровых событий и загружен менеджером игровых сообщений
на сервере и клиенте. Игровое сообщение имеет уникальное имя и
количество полей данных или полей данных где каждая запись может быть
вещественным, целым числом или строкой. Простейшие игровые события
описаны в resourcesgameevents.res. Например, ниже приводится определение
для игрового сообщения "player_death" генерируемого когда игрок
умирает:
"player_death" // a game event, unique name may be 32 characters long
{
"userid" "short" // user ID who died
"attacker" "short" // user ID who killed
}
Доступные типы для полей таблиц:
string - строка завершаящаяся нулевым символом
bool - беззнаковое целое, 1 bit
byte - знаковое целое, 8 bit
short - знаковое целое, 16 bit
long - знаковое целое, 32 bit
float - вещественное, 32 bit
Менеджер событий должен загрузить ресурсные файлы всех игровых событий перед тем как буте использоваться любое игровое событие:
gameeventmanager->LoadEventsFromFile("resource/gameevents.res");
После чего сервер может генерировать эти события, которые храняться как
объекты KeyValues. Класс KeyValues предоставляет простой механизм чтения
и записи его полей с данными:
KeyValues * event = new KeyValues( "player_death" );
event->SetInt("userid", pVictim->GetUserID() );
event->SetInt("attacker", pScorer->GetUserID() );
gameeventmanager->FireEvent( event );
После генерации события, выделенная память для KeyValues высвобождается
игровым менеджером событий. Менеджер событий производит сериализацию
данных используя данную информацию о типе из ресурсного файла и
распределяет события всем клиентам. На стороне клиента объект слушатель
регистрироваться как слушатель событий определенного события. Нет
ограничения на количество слушателей для одного события (подобно
сообщениям пользователей, которые могут утсанавливать только одну
функцию ответа на сигнал).
Сообщения пользователя
Сообщения пользователя, подобно игровым сообщениям, имеют уникальные
имена которые используются для их идентификации. Подобно менеджеру
событий, сообщения пользователя контроллируются классом CUserMessages,
который регистрирует и устанавливает ответы на вызов. Но в отличие
игровых сообщений, сообщения пользователей не сериализируются и
десериализируются автоматически, это должно производится вручную во
время отправки сообщения пользователя а также во время получения их на
клиенте. Поэтому код сервера и клиента должны обновляться каждый раз
когда пользователь производит изменения.
Сообщения пользователя регистрируются в общей функции
RegisterUserMessages(). Каждое сообщение хранит уникальное имя и
сообщает системе его размер в байтах (или -1 если сообщение имеет
динамический размер подобно строкам):
usermessages->Register( "MyMessage", 2 ); // send 2 bytes payload
Для отправки сообщений пользователя код сервера должен указывать группу
клиентов получателей, что делается первым делом при создании объекта
CRecipientFilter. Отправляя сообщение пользователя начинается коммандой
UserMessageBegin() сопровождающейся блоком комманд WRITE_* для
заполнения сообщения пользователя данными (смотрите enginecallback.h для
всех доступных WRITE_* комманд). Сообщения закрываются и отправляются
во время выполнения комманды MessageEnd():
CSingleUserRecipientFilter filter ( pBasePlayer ); // set recipient
filter.MakeReliable(); // reliable transmission
UserMessageBegin( filter, "MyMessage" ); // create message
WRITE_BYTE( 4 ); // fill message
WRITE_BYTE( 2 ); // fill message
MessageEnd(); //send message
Невозможно начать отправку еще одного блока сообщения пользователя в то
время как передается первый. Емкость сообщения ограничена 255 байтами,
если его размер превышает объем одного блока, сообщение не отправляется и
выдается предупреждение.
На стороне клиента, должна быть вызвана общая функция
RegisterUserMessages() для регистрации того же имени и размера что и на
сервере. Также должен быть установлен дескриптор сообщения (ответ на
вызов) для кажого сообщения пользователя. Нет возможности устанавливать
несколько дескрипторов для одного сообщения полльзователя. Дескриптор
сообщения получает сообщение как объект bf_read (класс для чтения
битовых потоков) и должна быть определена функция как показано ниже:
// declare the user message handler
void __MsgFunc_MyHandler( bf_read &msg )
{
int x = msg.ReadByte();
int y = msg.ReadByte();
}
// register message handler once
usermessages->HookMessage( "MyMessage", __MsgFunc_MyHandler );
Обычно используется вспомогательный макрос подобно HOOK_MESSAGE или
HOOK_HUD_MESSAGE для установки функции ответа (определено в
hud_macros.h).
Временные энтити
Для создания визуальных эффектов которые существуют коротоке время, не
самый лучши путь создавать и уничтожать настоящие энтити для выполнения
этой работы, поскольку это вызовет значительное переполнение сети.
Визуальные эффекты больше похожи на события "сделал-забыл" и если их
пает данных теряется нет необходимости его передавать заново.
Временные энтити являются энтитями клиентской стороны которые могут быть
порождены кодом сервера или клиента. Они не имеют индексов или
EHANDLE-ов как у нормальных энтитей. После того как сервер породил
временную энтить, она не может быть изменена. В остальном, временные
энтити подобны нормальным энтитям. они все порождаются от одного и того
же базового класа CBaseTempEntity на сервере и C_BaseTempEntity на
клиенте. Они имеют уникальное имя класса, сетевые переменные-члены и
определенные таблицы отправки и получения. На стороне сервера
присутствует синглетон-объект для каждого класса временных энтитей,
который используется для создания новых временных энтитей вызовом
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;
// Send it over the wire
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 )
{
// temp entity has been spawned, do something
Msg("Create effect at position %.1f,%.1f,%.1fn",
m_vecPosition[0], m_vecPosition[1], m_vecPosition[2] );
}
public:
Vector m_vecPosition;
};
// declare the client receive table
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().
Таблицы строк
Таблицы строк это контейнеры дубирующихся данных с индексироваными
записями которые содержат в кажой записи строку и опциональные двоичные
данные (до 4kB). Таблицы строк создаются на сервере и обновляются
немедленно и гарантированно на всех клиентах. Движок предоставляет
интерфейс INetworkStringTableContainer для кода сервера и клиента для
управления этими таблицами строк. Для создания нового объекта таблицы
строк, должны быть указаны уникальное имя таблицы и максимальное число
записей (должно быть произведения 2). Когда таблица строк создается,
возвращается интерфейс доступа INetworkStringTable который может быть
использван для добавления новых, поиск существующихзаписей или изменение
их двоичного содержимого. Строка записи не может изменяться после того
как была создана. Такие же интерфейсы используются клиентской стороной
для поиска или доступа таблиц строк, но не могут вносить изменения.
Таблицы строк это очень простой и эффективный путь для передачи больших
блоков текстовых строк (имена материалов или ресурсов и т.д.). Главная
идея состоит в том чтобы сохранить траффик передавая только индексы
таблицы для часто используемых строк. Поэтому одна и та же текстовая
строка никогда не передается дважды. Still, изменения двоичных данных
может вызвать обширную загрузку сети в связи с тем что они передаются
как двоичные данные и не сжимаются. Если двоичные данные обновляются
слишком часто, нужно принимать решение использовать вместо них энтить
объект.
Серверный код:
INetworkStringTable *g_pMyStringTable = NULL;
CServerGameDLL::CreateNetworkStringTables( void )
{
g_pMyStringTable= networkstringtable->CreateStringTable
( "MyStringTable", 32 );
...
}
void InitMyStringTable()
{
int data = 42; // some binary data
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") )
{
// Look up the table
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);
}