{{notification.text}}

MirGames

В процессе создания игры мы постоянно сталкиваемся с определенными проблемами, которые как то надо решать… Иногда трудности решения проблемы влияют и на сюжет самой игры, и на игровые моменты. В этом разделе будут публиковаться статьи, посвященные созданию Ad Infinitum. Возможно, вы сейчас как раз делаете какую нибудь игру и наши решения Вам помогут ;)

Мы отнють не утверждаем, что представленные нами решения являются наилудшими, мы просто делимся своим опытом

Данная статья была взята с сайта ai.extractor.ru с разрешения Михаила Бесчетнова.

Введение

Ну что же, на этот раз хотелось бы рассказать, как в Ad Infinitum организована связь между клиентами и сервером и отправка/обработка сообщений :)

Для оной связи использовались компоненты Indy v9 (http://www.nevrona.com/indy) с отключенным (обязательно) Nagle-алгоритмом. Десятая версия мне не понравилась, хотя утверждается, что там есть множество интересных новых фич. В качестве протокола выбрал TCP/IP, т.к. нужна гарантированная доставка, а изобретать свой «гарантированный UDP» нет никакого интереса. Кроме того, доставка почти всех данных обязательна и пакеты должны обрабатываться в том порядке, в каком они были оправлены с сервера.

Итак, приступим…

Те, кто знаком с Indy, знают, что на каждого клиента там сервер создает отдельный поток (Thread). Поскольку потоки эти никак особенно не синхронизированы с основным игровым циклом, я решил разместить обработку пакетов от клиента (и клиентом от сервера, где все то же самое, только поток всегда один) в основном цикле, а в самом потоке только производить чтение данных из сети и формирование «игровых пакетов».

Общая идея

Таким образом, схема, которую предстояло реализовать, включала в себя следующие действия:
  1. Потоки клиентов читают данные из сокета
  2. Данные из сокета образуют собой игровые пакеты и помещаются в единый буфер входящих пакетов (названый «TInbox»)
  3. В основном цикле программы каждый игровой такт (первоначально было 100, а теперь 20 раз в секунду) обрабатывается некое количество пакетов, тем самым распределяя во времени процесс обработки входящих данных. Можно так же обрабатывать и сразу все, что приходит.

Теперь опишу весь цикл от отправки сообщения, до обработки.

Клиент хочет проверить пинг до сервера. На самом деле это не совсем пинг, это время реакции сервера, которое включает в себя 20ms (т.е. продолжительность одной итерации основного цикла). У Вас это может быть реализовано как то иначе, но я рассказываю о своей схеме…

  1. Клиент формирует игровой пакет. Для этого заполняются поля записи:

    Ttcp_SystemPingPacket=record
      RecordType: Byte;
      MSec: Int64;
    end;


    RecordType — код пакета. По нему получатель будет определять, как именно воспринимать дальнейшие данные.
    MSec — результат QueryPerformance
  2. Клиент отсылает пакет. К пакету перед RecordType еще прилепляется размер этого самого пакета. Зачем, станет ясно попозже :)
  3. Сервер, обнаружив поступление данных от клиента, резервирует слот в входящем буфере, читает данные по мере поступления (хотя по идее данные должны приходить без задержек).
  4. Сервер в основном цикле обнаруживает новый поступивший игровой пакет, обрабатывает, и по той же схеме отсылает обратно (это же пинг ;).
  5. Клиент обрабатывает пакет как и сервер и высчитывает время отклика сервера.

Одно из самых трудных мест во всем этом — это сам буфер входящих пакетов, TInbox. Первоначально он был реализован в виде TList. Но размер его постоянно меняется, пакеты читаются, удаляются… В результате, периодически вылетали ListOutOfBounds (TList.Count менялся прямо во время пробега по списку, ведь клиентские потоки, как уже говорилось, не синхронизированы с основным). Так что пришлось немного подумать над новой системой…

Структура классов

Был придуман буфер в виде статичного списка:


TInbox=class
public
  LastCreatedIndex: Integer;
  LastExecutedIndex: Integer;
  Packets: array[0..INBOX_TOTALPACKETS-1]of record
    Buf: TByteArray;
    iDescr: Integer;
    iSize: Integer;
    PIndex: Integer;
    PacketStatus: Byte;
  end;
  constructor Create;
  {$IFDEF SERVER}
  procedure AddBuffer(AThread: TIdPeerThread; oFlags: Integer);
  {$ENDIF}
  {$IFDEF CLIENT}
  procedure AddBuffer(oFlags: Integer);
  {$ENDIF}
  function ReserveFree: Integer;
  procedure ProcessSinglePacket(_TempBuffer: Pointer; oInd: Byte;
    iSize: Integer; oDescr: Integer);
  procedure ProcessFirstPacket;
  procedure Clear;
end;


Начнем с конца:
Clear — Понятно, очищает буфер
ProcessFirstPacket — Ищет первый свободный и готовый к обработке слот в входящем буфере. Обнаружив, вызывает ProcessSinglePacket, передавая ссылку на данные игрового пакета, хранящегося в слоте
ProcessSinglePacket — Обрабатывает указанный игровой пакет
ReserveFree — Резервирует свободный слот для формирования пакета
AddBuffer — Производит чтение данных из сокета. Как видим, у клиента и сервера заголовки чуть отличаются
Create — создания и инициализация буфера
Packets — Сам статический массив со слотами. Структура: Buf — игровой пакет
iDescr — идентификатор клиента (юзается в Indy, чтобы определять, от кого что пришло)
iSize — размер игрового пакета, хранящегося в текущем слоте
PIndex — индекс игрового пакета, хранящегося в текущем слоте (о нем чуть далее)
PacketStatus — Статус слота. Возможные значения: INBOX_PACKETSTATUS_FREE — Слот свободен для записи
INBOX_PACKETSTATUS_CREATION — Игровой пакет в слоте на стадии формирования. Он заполняется данными из сокета и закрыт для обработки.
INBOX_PACKETSTATUS_READY — Игровой пакет в слоте сформирован и готов к обработке
INBOX_PACKETSTATUS_PROCESSING — Игровой пакет в слоте в данный момент обрабатывается LastCreatedIndex — Последний присвоенный очередному игровому пакету индекс
LastExecutedIndex — Индекс последнего обработанного игрового пакета

Общая идея в деталях

В разделе «Общая идея» я описал в в общих чертах шаги, предпринимаемые отправителем и получателем сообщений. Теперь рассмотрим кое-что поподробней.

Получатель ждет данных. Первым байтом, который он получит, будет флаг данных, которые пойдут дальше. Это может быть как отдельный игровой пакет, так и Комплексный пакет (см.ИСХОДЯЩИЙ БУФЕР) или даже Сжатый пакет. Допустим, получатель определяет, что далее ему надо читать одиночный пакет. Далее, получатель резервирует в TInbox свободный слот, т.е. слот со статусом INBOX_PACKETSTATUS_FREE. В начале работы все слоты будут свободны, поэтому будет зарезервирован первый же. Обнаружив свободный слот, получатель отмечает его тут же статусом INBOX_PACKETSTATUS_CREATION. Все, слот забит. Теперь, другие клиентские потоки (если это сервер) будут пропускать этот зарезервированный слот, резервируя следующие. Далее, получатель читает из сокета еще 2 байта (размер пакета), а затем и сам пакет и складывает его в Packets.Buf. Завершив чтение, слоту присваивается статус INBOX_PACKETSTATUS_READY. Теперь слот (и, соответственно, пакет в нем) готов к обработке в основном игровом цикле. Такая схема позволяет исключить запись множеством клиентских потоков одновременно в один слот.
В основном цикле, каждую игровую итерацию получатель ищет первый пакет в буфере с флагом INBOX_PACKETSTATUS_READY

procedure TInbox.ProcessFirstPacket;
var
i: Integer;
begin
  if LastCreatedIndex>LastExecutedIndex then
  for i:=0 to INBOX_TOTALPACKETS-1 do
  begin
    if Packets[i].PacketStatus=INBOX_PACKETSTATUS_READY then
    if Packets[i].PIndex=LastExecutedIndex then
    begin
      Packets[i].PacketStatus:=INBOX_PACKETSTATUS_PROCESSING;
      Inc(LastExecutedIndex);
      ProcessSinglePacket(Packets[i].@Buf,0,Packets[i].iSize,Packets[i].iDescr);
      Packets[i].PacketStatus:=INBOX_PACKETSTATUS_FREE;
      Exit;
    end;
  end;
end;


Как видим, обнаружив первый не обработанный игровой пакет получатель отмечает его статусом INBOX_PACKETSTATUS_PROCESSING, обрабатывает, а затем вновь освобождает его (INBOX_PACKETSTATUS_FREE).

Теперь поясню по поводу Packets.PIndex. Эмулируем следующую последовательность:
Создание Ad Infinitum: Сетевое взаимодействие

Всего имеем 4 игровые итерации в качестве примера. Первая строка — номера итераций, остальные строки символизируют слоты в массиве Packets. Числа в слотах — номера игровых пакетов в той последовательности, в которой они приходили к получателю.

Итерация 1. К этому моменту заполнено 4 слота. Все готовы к обработке.
Итерация 2. Получатель ищет готовый слот и обрабатывает его [1]
Итерация 3. Получатель ищет готовый слот и обрабатывает его [2]
Итерация 4. Между 3 и 4 итерацией в первый слот был положен новый входящий пакет [5]. Получатель ищет первый готовый к обработке и выходит, что обрабатывать надо пакет [5], потому что он первый. Хотя по идее нужно бы заняться [3].

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

Маленькое пояснение по поводу статуса INBOX_PACKETSTATUS_PROCESSING. В принципе, в нем может и не возникнуть необходимости, т.к. обработка проходит в основном цикле, и пока не будет обработан пакет N, следующий за ним обрабатываться не должен. Но конкретно у нас реализованы модальные окна на основе игрового GUI, так что может случиться так, что программа слегка «встанет» при обработки, например, важных сообщений от сервера. Вот поэтому, обрабатываемый пакет маркируется, далее вызывается модальное окно и, пока оно не закрыто, выполняется Application.HandleMessage.

Исходящий буфер

Тут все довольно просто. От сервера уходит несколько сотен пакетов в секунду. Поскольку Nagle для ускорения процесса отключен, исходящие пакеты не будут склеиваться и пойдет немалая потеря на заголовках TCP/IP. Поэтому все исходящие пакеты склеиваются в один большой буфер (TByteArray), который 10 раз в секунду отправляется уже по назначению. Вот такой вот буфер я и назвал Комплексным пакетом. Если пакет вырастает значительно (скажем, до 50-100 байт), можно сжать его ZLib'ом. Тогда это уже Сжатый пакет и формат его еще несколько усложняется. Ко всему прочему добавлю шифрацию всего трафика, так что получается приличная каша в коде. Но все пока работает. :)

Описал все несколько хаотично, так что если будут конкретные вопросы по статье, буду рад ответить :). Возможно, в виде дополнительной статейки :)
05.06.05 14:12