Загрузка...
 

Программный поток (Program Flow)

  • Является синонимом понятия "процесс выполнения программы".1


Intro

Когда игрокодер с головой погружен в написание кода, часто очень трудно держать под контролем все внесённые в код изменения и поддерживать порядок в исходных файлах. Здесь не обойтись без понимания принципов работы программного потока. Грамотно структурированное приложение (или фреймворк) позволяет легко ориентироваться в своём исходном коде и вносить в него необходимые изменения. Имея на руках готовый дизайн-документ игры (Ты же сделал его, не так ли?), осталось лишь на его основе построить структуру процесса выполнения программы (structure of the processing flow).

Типичная программа начинает свою работу с инициализации всех своих "систем" и данных. Затем она входит в т.н. главный цикл (main loop). Именно здесь происходит само выполнение программы. В зависимости от текущего игрового стейта (главный экран, игровой процесс и т.д.) игровые ввод и вывод обрабатываются по-разному.

При создании любого игрового приложения игрокодер, как правило, выполняет следующие шаги:

  1. Инициализация систем (Windows, графика, ввод, звук и т.д.).
  2. Подготовка данных (загрузка конфигурационных файлов).
  3. Конфигурирование стейта по умолчанию (default state; обычно это стейт с титульным экраном).
  4. Старт главного цикла программы (main loop).
  5. Определение текущего стейта и его обработка (путём обработки по необходимости ввода и вывода).
  6. Возврат к шагу 5 до тех пор, пока приложение не будет закрыто, после чего переход к шагу 7.
  7. Очищаем данные, освобождаем ранее занятые ресурсы.
  8. Освобождаем ресурсы системы (закрываем окна, обнуляем графику, ввод и т.д.).

Шаги 1-3 типичны при создании любой игры: настройка текущей системы, загрузка необходимых сопутствующих файлов (графика, звуки и т.д.), подготовка игрового процесса (actual gameplay). Игровое приложение обычно тратит уйму времени на определение текущего стейта и его обработку (шаг 5). Этот шаг можно разделить на 3 части:

  • докадровая обработка (pre-frame processing),
  • покадровая обработка (per-frame processing),
  • послекадровая обработка (post-frame processing).

Докадровая обработка выполняет ряд простых операций, вроде получения текущего времени (для зависимых от времени событий, например синхронизации) или обновления положения/состояния объектов сцены.
Покадровая обработка также обновляет статус объектов (если это не было сделано в докадровой обработке) и рендерит графику.
Послекадровая обработка выполняет оставшиеся функции вроде синхронизации временных операций или даже вывод уже отрендеренной графики.
В твоей игре может быть несколько докадровых стейтов: один для меню, один для организации внутриигрового процесса и т.д. Грамотная организация стейтов в игре называется стейт-процессинг (state-processing) и является ключом к созданию шустрого игрового приложения.
Удаление данных и закрытие приложения (шаги 7 и 8) освобождают системные ресурсы, которые выделялись на стадии препроцессинга. Графика должна быть удалена из памяти, окно приложения уничтожено и т.д. Пропуск данных шагов категорически противопоказан.
При создании программного потока каждому шагу сопоставлен соответствующий блок исходного кода. Поэтому, чем яснее и чётче структура этого кода, тем проще будет на его основе создать приложение. Часто код размещают в отдельных lib-файлах (системное ядро, графическое ядро, звуковое ядро и т.д.), подключаемых к проектам Visual C++ при необходимости.

Стейты и процессы (States and Processes)

Оптимизация программного потока является одной из главных целей любого игрокодера. Исходный код небольшого размера прост в управлении. Но по мере роста приложения работать с ним становится всё сложнее. Особенно когда для изменения небольшой функции приходится переписывать огромные куски кода.
Допустим, разработка игры в процессе и ты решил добавить новую фичу, которая открывает в игре экран инвентори по нажатию клавиши i. Экран инвентори (inventory display screen) может быть активирован только во время игры, а не на главном экране игрового приложения. А значит необходимо внедрить код, определяющий нажатие клавиши i и открывающий по нажатию окно inventory.
Если тупо сделать одну функцию, которая рендерит каждый экран (display screen) независимо от того, что делает игрок в игре, то очень скоро функция рендеринга станет очень большой, сложной и не имеющей возможности отследить, какой из стейтов активен в данный момент.
На помощь придут две методики: Системы управления стейтами и процессами.

Рис.1 Стек стейтов позволяет помещать (push) и удалять (pop) из него стейты по мере необходимости.
Рис.1 Стек стейтов позволяет помещать (push) и удалять (pop) из него стейты по мере необходимости.

Стейты приложения (Application States)

Стейт - это сокращение от "стейт операции" (state of operation), представляющий собой текущее состояние (процесс), которое приложение обрабатывает. Главное меню игры - это стейт. Игровой процесс - это тоже стейт, также как и экран inventroy.
При добавлении в приложение различных стейтов, необходимо также продумать способ определить, как их обрабатывать в зависимости от текущего стейта операции (который постоянно меняется с одного на другой). Процесс определения необходимого стейта может обернуться таким ужасающим кодом:

switch(CurrentState) {
 case STATE_TITLESCREEN:
  DoTitleScreen();
  break;
 case STATE_MAINMENU:
  DoMainMenu();
  break;
 case STATE_INGAME:
  DoGameFrame();
  break;
}

Со временем такой список стейтов значительно разрастётся. И если весь его обрабатывать в каждом кадре, то получим значительное снижение производительности.
На практике вместо такого цикла switch...case применяют т.н. программирование на основе стейтов (state-based programming; SBP). Его суть заключается в ветвлении выполнения приложения на основе т.н. стэка стейтов (stack of states). В этом случае каждый стейт представляет собой объект или набор функций, которые добавляются в стек при необходимости. Более неиспользуемые функции удаляются из стека (См. Рис.1).
Стейты добавляются, удаляются и обрабатываются (process) с помощью менеджера стейтов (state manager). Будучи выброшенным (popped) из стека стейтов, самый верхний стейт удаляется (не насовсем), и очередь на обработку сдвигается так, что в обработку идёт следующий за ним стейт. Менеджер стейтов должен получать указатели (pointers) на функции (которые представляют те или иные стейты). При отправке стейта на обработку его указатель добавляется в стек к остальным. Задача игрокодера - своевременно вызывать менеджер стейтов, который будет обрабатывать самый верхний стейт в своём списке. На самом деле с менеджером стейтов работать нетрудно. Рассмотрим пример его реализации:

class cStateManager
{
 // Структура, которая хранит указатель функции и связный список (linked list)
 typedef struct sState {
  void (*Function)();
  sState *Next;
 } sState;

 protected:
  sState *m_StateParent; // Самый верхний стейт в стеке ("голова" стека)

 public:
  cStateManager() {m_StateParent=NULL;}
  `cStateManager()
  {
   sState *StatePtr;

   // Удаляем все стейты из стека
   while((StatePtr = m_StateParent) != NULL) {
    m_StateParent = StatePtr->Next;
    delete StatePtr;
  }

 // Помещаем (push) функцию в стек.
 void Push(void (*Function)())
 {
  // Не помещаем нулевое значение
  if(Function != NULL)
   {
    // Выделяем память (allocate) под новый стейт и помещаем (push) его в стек.
    sState *StatePtr = new sState;
    StatePtr -> Next = m_StateParent;
    m_StateParent = StatePtr;
    StatePtr->Function = Function;
   }
 }

 BOOL Pop()
 {
  sState *StatePtr = m_StateParent;
  
  // Удаляем "голову" стека (если есть)
  if(StatePtr != NULL)
  {
   m_StateParent = StatePtr->Next;
   delete StatePtr;
  }

  // Возвращаем TRUE если ещё остались стейты и FALSE - если нет.
  if(m_StateParent == NULL)
   return FALSE;
  return TRUE;
 }

 BOOL Process()
 {
  // Если больше стейтов не осталось, возвращаем ошибку.
  if(m_StateParent == NULL)
   return FALSE;
  // Обрабатываем самый верхний стейт в стеке (если есть).
  m_StateParent->Function();
  return TRUE;
 }
};

Закрыть
noteОбрати внимание

В вышеприведённом коде виден идентификатор Next. Такими снабжается код, оснащённый поддержкой связных списков. Эта технология широко распространена во многих языках программирования. В зависимости от реализации, каждый член связного списка обладает следующими идентификаторами, хранящими информацию о своих "соседях" по последовательности: Next, Previous, First, Last и т.д.


Внедрив класс cStateManager в свой код, ты можешь добавлять в его объект новые стейты по необходимости. И во время вызова функции рендеринга кадра можно вызывать метод Process только на функции нужного стейта. Вот пример применения класса cStateManager:

cStateManager SM;

// Макрос для упрощённого вызова окна сообщения
#define MB(s) MessageBox(NULL, s, s, MB_OK);

// Прототипы функций стейтов (должны строго следовать данному прототипу).
void Func1() { MB("1"); SM.Pop(); }
void Func2() { MB("2"); SM.Pop(); }
void Func3() { MB("3"); SM.Pop(); }

int PASCAL WinMain(HINSTANCE hInst, HINSTANCE hPrev, LPCSTR szCmdLine, int nCmdShow)
{
 SM.Push(Func1);
 SM.Push(Func2);
 SM.Push(Func3);
 while(SM.Process() == TRUE);
}


Вышеприведённая программа позволяет отслеживать состояние трёх стейтов, каждый из которых выдаёт окно сообщения (MessageBox) с определённой цифрой. Каждый стейт выдаёт сообщение прямо из стека и автоматически передаёт очередь другому стейту до тех пор, пока не закончатся все стейты и программа не будет завершена.
А теперь прикинь, если внедрить такой связный список (стек) в покадровую выборку сообщений (per-frame message pump). Допустим, что надо выдать сообщение игроку, но, как на зло, игра находится в стадии прорисовки внутриигровых объектов. В этом случае достаточно просто поместить функцию сообщения в стек и вызвать метод Process при подготовке следующего кадра.

Процессы (Processes)

Если ты используешь отдельные модули для обработки ввода, сети и звука, то, вместо того, чтобы вызывать каждый из них по отдельности, можно создать объект менеджер процессов (ProcessManager), который будет делать это за тебя.

class cProcessManager
{
 // Структура, хранящая указатель функции и связный список
 typedef struct sProcess
 {
  void (*Function)();
  sProcess *Next;
 } sProcess;

 protected:
  sProcess *m_ProcessParent; // Самый верхний стейт в стеке ("голова" стека).
 
 public:
  cProcessManager() {m_ProcesParent=NULL;}
  
  `cProcessManager()
  {
   sProcess *sProcessPtr;

   // Удаляем все процессы из стека
   while((ProcessPtr = m_ProcessParent) != NULL)
   {
    m_ProcessParent = ProcessPtr->Next;
    delete ProcessPtr;
   }
  }

 // Добавляем функцию в стек
 void Add(void (*Process)())
 {
  // Не добавляем нулевое значение
  if(Process != NULL)
   {// Выделяем память (allocate) под новый процесс и добавляем (push) его в стек.
    sProcess *ProcessPtr = new sProcess;
    ProcessPtr->Next = m_ProcessPtr;
    m_ProcessParent = ProcessPtr;
    ProcessPtr->Function = Process;
   }
 }

 // Обрабатываем все функции
 void Process()
 {
  sProcess *ProcessPtr = m_ProcessParent;

  while(ProcessPtr != NULL)
  {
   ProcessPtr->Function();
   ProcessPtr = ProcessPtr->Next;
  }
 }

};

Этот пример аналогичен cStateManager, который рассмотрен выше. Единственное отличие - cProcessManager может только добавлять процессы в стек. Он не удаляет их.
Вот пример применения менеджера проецссов:

cProcessManager PM;

// Макрос для упрощения создания диалоговых окон.
#define MB(s) MessageBox(NULL, s, s, MB_OK);

// Прототипы функций процессов (process functions).
void Func1() { MB("1"); }
void Func2() { MB("2"); }
void Func3() { MB("3"); }

int PASCAL WinMain(HINSTANCE hInst, HINSTANCE hPrev,
               LPSTR szCmdLine, int nCmdShow)
{
 PM.Add(Func1);
 PM.Add(Func2);
 PM.Add(Func3);
 PM.Process();
 PM.Process();
}

Рис.2 Стек процессов состоит из часто вызываемых функций. Каждая функция, добавленная в менедже процессов, выполняется при общем вызове cProcessManager::Process.
Рис.2 Стек процессов состоит из часто вызываемых функций. Каждая функция, добавленная в менедже процессов, выполняется при общем вызове cProcessManager::Process.

Если заметил, при каждом вызове метода Process вызываются все процессы в стеке (См. Рис.2). Часто бывает очень полезно быстро вызывать опередлённые функции. Можно создать отдельные менеджеры процессов для разных ситуаций. К примеру, один обрабатывает сеть и пользовательский ввод (input), другой - работает над звуком и ещё чем-нибудь.

Работа с данными приложения (Handling an Application Data)

Все приложения оперируют какими-либо данными. Особенно много таких данных обрабатывают игры. Каждый бит информации о персонаже игрока, его здоровье, уровне прокачки и т.д. является данными приложения (application data). Каждый раз, покидая игру, данные игрока сохраняются для того, чтобы они могли быть загружены в следующий раз.

Система работы с данными (Data Packaging System)

Самый простой способ организовать работу с данными приложения - это создать систему работы с данными (data packaging system), которая будет ответственна за сохранение данных игры и их последующую загрузку. Создадим объект, который содержит буфер данных (data buffer) и добавим в него несколько функций, сохраняющих и загружающих данные:

class cDataPackage
{
 protected:
  // Буфер данных и его размер
  void *m_Buf;
  unsigned long m_Size;

 public:
  cDataPackage() {m_Buf=NULL; m_Size=0;}
  `cDataPackage() {Free();}

 void *Create(unsigned long Size)
  {
   // Освобождаем ранее созданный буфер
   Free();

   // Выделяем память и возвращаем указатель
   return (m_Buf = (void*)new char[(m_Size = Size)]);
  }

 // Освобождаем ранее выделенную память
 void Free() {delete m_Buf; m_Buf = NULL; m_Size = 0;}

 BOOL Save(char *Filename)
 {
  FILE *fp;

  // Убедись, что есть, что записывать
  if(m_Buf != NULL && m_Size) {
   // Открываем файл, пишем размер данных и сами данные
   fwrite(&m_Size, 1, 4, fp);
   fwrite(m_Buf, 1, m_Size, fp);
   fclose(fp);
   return TRUE;
   }
 }

 return FALSE;

 void *Load(char *FileName, unsigned long *Size)
 {
  FILE *fp;

  // Освобождаем основной буфер
  Free()

  if((fp=open(Filename, "rb"))!=NULL
  {
   // Читаем размер данных и сами данные
   fread(&m_Size, 1, 4, fp);
   if((m_Buf = (void*) new char[m_Size]) != NULL)
   {
    fread(m_Buf, 1, m_Size, fp);
   fclose(fp);

   // Сохраняем возвращаемый размер данных
   if(Size != NULL)
    {*Size = m_Size;}

   // Возвращаем указатель
   return m_Buf;
   }

  return NULL;
  }
 }

 };

Класс cDataPackage содержит 4 функции (вообще, их 6, если считать конструктор и деструктор класса).
Функция Create выделяет блок памяти размера, заданного в её параметре (Size). Функция Free освобождает данный блок памяти. Функции Save и Load сохраняет и загружает данный, используя указанное в параметре имя файла (filename).
Обрати внимание, что функции Create и Load возвращают указатель на буфер данных. Можно подставить свой собственный указатель на буфер данных.

Проверяем систему работы с данными

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

// Структура, содержащая имена
typedef struct {char Name[32];} sName;

int PASCAL WinMain(HINSTANCE hInst, HINSTANCE hPrev, LPSTR szCmdLine, int nCmdShow)
{
 cDataPackage DP;
 DWORD Size;

 // Создаём систему работы с данными (data package)
 // и передаём ей указатель на созданный пользовательский тип структуры данных sName
 sName *Names = (sName*)DP.Create(64);

 // Раз уж у нас есть под каждое имя 64 байта, а каждое имя содержит 32 байта,
 // то можно сохранять двойные имена.
 strcpy(Names[0].Name, "Jim");
 strcpy(Names[1].Name, "Adams");

 // Сохраняем имена на жёсткий диск и освобождаем буфер данных
 DP.Save("names.dat");
 DP.Free();

 // Загружаем имена с жёсткого диска. Когда функция загрузки
 // вернёт значение, его размер будет 64 байт.
 Names = (sName*)DP.Load("names.dat", &Size);

 // Показываем имена на экране
 MessageBox(NULL, Names[0].Name, "1st Name", MB_OK);
 MessageBox(NULL, Names[1].Name, "2nd Name", MB_OK);

 // Освобождаем структуру данных (data package).
 DP.Free()
}
Размер буфера данных (64 байта) достаточен для сохранения в нём обоих частей имени персонажа. Каждая из частей имени хранится в отдельном сегменте, размер которого 32 байт.
Размер буфера данных (64 байта) достаточен для сохранения в нём обоих частей имени персонажа. Каждая из частей имени хранится в отдельном сегменте, размер которого 32 байт.

Из кода видно, что 64-байтный буфер данных с именем делится на 2 блока по 32 байт каждый.

Применение системы работы с данными приложения

Возможности применения на практике системы работы с данными огромны. Оперируя двумя маленькими объектами дата-пэкэджа (data package), мы можем создавать на них указатели, при этом держа все данные приложения в едином объекте, который может сам себя сохранять и загружать.

Источники:


1. Jim Adams. Programming Role Playing Games with DirectX 8.0. - Premier Press. 2002

Contributors to this page: slymentat .
Последнее изменение страницы Вторник 05 / Май, 2020 14:05:20 MSK автор slymentat.

Помочь проекту

Яндекс-деньги: 410011791055108