13 февраля 2012

MenuManager, стартуем...

Меню в играх - очень интересная и обширная тема... Кто-то оставляет в нем всего две кнопки Play и Exit, а кто-то наполняет целым интерактивным миром, в котором игрок проводит довольно много времени. Конечно, это дело вкуса... Но так или иначе, а главное меню присутствует в большинстве современных игр! Поэтому у нас не остается выбора - необходимо сделать функционал такого меню, чтобы использовать его в своих играх!


То не сказка, а присказка
Сегодня мы только начнем делать полноценный менеджер меню. Все же это дело не из простых, да и для понимания обычно проще, когда весь код разложен по полочкам, а не свален в одну кучу. Поэтому будем делать итоговую демку в несколько "присестов", сегодня первый из них - напишем основу, разберемся в терминах и набросаем простой пример с использованием GlScene.

Предыстория
Начну с того, как я раньше программировал меню. На самом деле почти все диалоговые окна дублировали код друг друга, бессвязно болтаясь по различным классам. То есть добавление одного подменю "Опции" к остальным пунктам "Помощь", "Выход", "Профили" и другим вызывало массу головной боли, приходилось выстраивать целые блоки показа/скрытия вроде вот таких:

if(OptionsClicked)then
begin
   Options.Show;
   MainMenu.Hide;
end;
..................................
if(GotoMainMenuClicked)then
begin
  Options.Hide;
  Help.Hide;
  Levels.Hide;
  Profiles.Hide;
  Exit.Hide;
  MainMenu.Show;
end;

И так для каждого подменю. А когда встал вопрос о добавлении нового вложенного диалога с вопросом "А вы действительно хотите удалить этот профиль?" и кнопками "да/нет", у меня от страха затряслись губы... :)

Формирование задачи
В общем, напрашивается одно замечательное решение - нужно унифицировать процесс создания игрового меню. То есть нам нужен механизм, который бы сам управлял вложенными страницами меню, показывал и скрывал определенные подменю, сделав нашу жизнь более удобной и простой!
Только что я употребил термин "страница меню", под этим я понимаю отдельное целостное подменю, которое при показе блокирует все остальные страницы. То есть нажали кнопку "Опции" и у нас вылезло окошко с настройками игры, при этом предыдущая страница меню скрывается или блокируется. Нажмем "Ок" и окно опций скроется, активизируется предыдущая страница меню, и мы выйдем на уровень выше, в главное меню.
Таким образом у нас будет выстраиваться цепочка из страниц меню:

Главное меню -> Профили -> Окно удаления профиля

Из этой цепочки видно, что при закрытии последнего окна, мы выйдем обратно в Профили, при зарытии которого выйдем еще на уровень выше. То есть страницы как бы добавляются в некий список, а затем выходят из него. Кто зашел последним, выйдет первым... Подобная система называется стековой и придумана не мною... :)
Для организации такого стека нам нужен только список, в который будем как бусины нанизывать новые и новые страницы меню. Не знаю как у вас, а у меня уже чешутся руки, готовые начать программировать! Приступим!

Страница меню, BasePage
Для начала организуем простую систему базовой страницы меню, от которой мы будем наследовать все остальные страницы, которые встретятся в игре.

  TBasePage = class
  protected
    fUnitTime: Single; // from 0 to 1
    fShowSpeed: Single;
    fHideSpeed: Single;

    fCurrentState: TPageState;
    fFinishState: TPageState;
  protected
    Procedure HideStep(aDeltaTime: Single); virtual;
    Procedure ShowStep(aDeltaTime: Single); virtual;
    Procedure HideComplete(); virtual;
    Procedure UpdateUnitTime(); virtual;
  public
    Function IsVisible: Boolean;
    Procedure Show; virtual;
    Procedure ForcedShow; virtual;
    Procedure Hide; virtual;
    Procedure Update(aDeltaTime: Single); virtual;
    Constructor Create();
  end;

Все ясно из названий: показать, скрыть и форсированно показать (то есть показать мгновенно, без проявления по альфе, без анимаций и т.д., необходимо при первом появлении меню). Хочу подчеркнуть, что TBasePage - это основа, простой класс пустышка с большим количеством виртуальных методов. А вот для программистов GlScene мы отнаследуем класс TGLBasePage, о котором поговорим чуть ниже. Вот в нем как раз и реализуем проявление по альфе, добавление объектов на экран, привязку библиотеки материалов и так далее.

Список страниц, ListPage
Пришло время организовать последовательность страниц, то есть наш стек, в который будут добавляться страницы, одна за другой.

  TPageList = class(TList)
  public
    Function GetPageByIndex(aPageIndex: Integer): TBasePage;
    Function GetPageByClass(aPageClass: TBasePageClass): TBasePage;
    Function AddPageByClass(aPageClass: TBasePageClass): TBasePage;
    Function GetIndexByClass(aPageClass: TBasePageClass): Integer;
    Function DeletePageByClass(aPageClass: TBasePageClass; aShouldFree: Boolean = false): Boolean;
    Function DeletePage(var aPage: TBasePage; aShouldFree: Boolean = false): Boolean;
  end;

Здесь немного сложнее, но смысл также ясен из названий самих методов: получить страницу, добавить страницу, удалить страницу. Поговорим немного подробнее о приставке -ByClass у некоторых методов.

Одна страница - один класс, все чисто и прозрачно
Чтобы управлять страницами нам нужно их как-то идентифицировать в нашем списке. Как это сделать? Для простоты я решил добавить поиск в списке по классу страницы. Таким образом можно быстро получить доступ до необходимого подменю:

MyList.GetPageByClass(TOptionsPage)

и вуаля, нам вернется экземпляр страницы опций, который был добавлен в список MyList! Чудеса, не правда ли? Не нужны ни константы, ни дополнительные переменные, ни строковые значения, ничего более... Нужно знать только сам класс!

Графичекая страница TGLBasePage
Мы уже разобрались с базовыми терминами, и теперь можно рассмотреть более практичный класс, который можно использовать для визуализации наших страниц меню. Я назвал его TGLBasePage, что отражает предназначение этого класса - использование в связке с GlScene. Наследуемся, конечно, от TBasePage и добавляем новый функционал, так или иначе связанный с графикой.
Описание самого класса довольно специфическое:
  TGLBasePage = class(TBasePage)
  protected
    fParent: TGLBaseSceneObject;
    fPageDummy: TGlDummyCube;
    Procedure HideComplete(); override;
  protected
    Procedure AddToScene; virtual;
    Procedure RemoveFromScene; virtual;
    Procedure UpdateUnitTime(); override;
    Procedure InitCustomObjects(fMaterialLibrary: TGlMaterialLibrary); virtual;
  public
    Procedure Show; override;
    Procedure InitGLPage(aGlParent: TGLBaseSceneObject; fMaterialLibrary: TGlMaterialLibrary);
  end;
Здесь уже и библиотека материалов, и базовый объект GlScene, и методы AddToScene()/RemoveFromScene() для корректного появления и пропадания нашей визуальной страницы.

Менеджер
Давайте попробуем объединить все это в одном менеджере, который бы магическим образом управлял бы всеми страницами сразу! По сути, мы добрались до центрального лица нашей системы-меню, генерала страниц, директора визуального театра!
Описание может показаться довольно мудреным, но, при ближайшем просмотре становится ясно, что на самом деле все прозрачно:
  TPageManager = class
  protected
    fParent: TGLBaseSceneObject;
    fMaterialLibrary: TGlMaterialLibrary;
    fRegistered: TPageList;
    fStack: TPageList;
  protected
    Procedure OnPageShowComplete(aPage: TBasePage);
    Procedure OnPageHideComplete(aPage: TBasePage);
  public
    Function RegisterPage(aPageClass: TGLBasePageClass): TGlBasePage;
    Function Push(aPageClass: TBasePageClass): TBasePage;
    Function ForcedPush(aPageClass: TBasePageClass): TBasePage;
    Function Pop: TBasePage;
    Function PopPage(aPageClass: TBasePageClass): TBasePage;
    Function GetPageByClass(aPageClass: TGLBasePageClass): TGlBasePage;
    Procedure Update(aDeltaTime: Single);
    Constructor Create(aGlParent: TGLBaseSceneObject; aMaterialLibrary: TGlMaterialLibrary);
  end; 

Разберу всего несколько самых основных методов:
  • RegisterPage(aPageClass: TGLBasePageClass) - регистрируем страницу, при этом создается экземпляр класса aPageClass и заносится во внутренний список fRegistered
  • Push(aPageClass: TBasePageClass) - добавить в стек новую страницу, которая берется из списка зарегистрированных страниц fRegistered
  • ForcedPush(aPageClass: TBasePageClass) - форсированно добавить страницу, при этом она моментально появляется на экране (зачастую нужно для бэков, которые сразу должны отобразиться)
  • Pop() - выбросить последнюю страницу из стека
  • PopPage(aPageClass: TBasePageClass) - выбросить страницу класса aPageClass из стека
Идея заключается в следующем... Сначала регистрируются все классы наших страниц. При этом менеджер создаст их экземпляры и поместит в свой внутренний список. Таким образом никаких дополнительных манипуляций с памятью нам не потребуются. Страницы будут существовать всегда, а вот показаны только те из них, которые находятся в стеке. Как раз для манипуляции страницами в стеке и предназначены методы Push() и Pop() с "говорящими" названиями... Первый - добавляет страницу в стек, при этом страница начнет показываться на экране. Второй метод выбрасывает последнюю страницу из стека.
Как видно из описания нашего класса, там все время фигурируют TGLBasePageClass, то есть я решил пропитать наш менеджер gl-страницами. Таким образом данный TPageManager работает только с графическими страницами, созданными на базе GlScene. Но на самом деле, немного переписав данный менеджер, его можно приспособить к работе с любым графическим движком (да хоть с VCL)!

Вообще, если данными наработками будет кто-то пользоваться, то конечно же я рассмотрю более подробно те или иные части всех вышеперечисленных классов. Но на данный момент я считаю важнее разобрать простой пример глубокой вложенности нескольких страниц друг в друга.

Небольшой пример использования
Чтобы не быть голословным, я решл набросать небольшую демонстрацию работы. Пока интерактив минимален - нажимаем "пробел" и у нас добавляется еще одна страница на экран, увеличивая вложенность страниц друг в друга. Хоть демонстрация и примитивна, но, уверен, уже этот пример сочными красками нарисует в воображении будущее меню, которое порадует даже изысканного игрока.
Выглядит наша демка так:


И сразу регистрация страниц:
  fPages[0] := TBaseMainMenu;
  fPages[1] := TOptionsPage;
  fPages[2] := TCreditsPage;
  fPages[3] := THelpPage;
  fPages[4] := TProfilePage;

  fPageManager := TPageManager.Create(fMainDummy, fMatLib);
  for i := low(fPages) to high(fPages) do
    fPageManager.RegisterPage(fPages[i]);

Как видно, я создал дополнительных пять классов (TBaseMainMenu, TOptionsPage, TCreditsPage, THelpPage, TProfilePage), отнаследованных от TGlBasePage. Каждый класс отвечает за свое подменю. Все они идентичны, поэтому рассмотрим только TBaseMainMenu:
  TBaseMainMenu = class(TGLBasePage)
  protected
    fHudSprite: TGlHudSprite;
    Procedure InitCustomObjects(fMaterialLibrary: TGlMaterialLibrary); override;
    Procedure UpdateUnitTime(); override;
  end;
Перекрыли два защищенных метода... В InitCustomObjects() создаем худ-спрайт, а в UpdateUnitTime() управляем его прозрачностью. В принципе, ничего сложного, обычные для GlScene дела.
Для красивости я решил не только добавлять страницы по нажатии на пробел, но также и убирать их, чтобы показать работу Pop()-метода. Пришлось завести переменную fReverse, показывающую в какую сторону движемся.
В итоге код обработки клавиши пробел разросся до неприлично большого состояния:
  if not SpacePressed and IsKeyDown(vk_Space) then
  begin
    if fPageReverse then
    begin
      fCurPop := fPageManager.Pop();
      if(fLastPop <> fCurPop) then
      begin
        fLastPop := fCurPop;
        fPageIndex := fPageIndex - 1;
        if fPageIndex = low(fPages) then
          fPageReverse := false;
      end;
    end
    else
    begin
      fPageManager.Push(fPages[fPageIndex]);
      fPageIndex := fPageIndex + 1;
      if fPageIndex > high(fPages) then
        fPageReverse := true;
    end;
  end;
Где все также красуются два простых метода Pop() и Push().
Таким образом, можно выделить простую схему работы с TPageManager'ом:
  • создаем все необходимые классы подменю, наследуя их от TGLBasePage
  • регистрируем все страницы с помощью RegisterPage()
  • при необходимости добавляем или убираем наши страницы с помощью Pop/Push
  • обновляем наш PageManager с помощью метода Update()
Если посмотреть на итоговый код основного модуля, то в нем большую часть занимают инициализация текстовой надписи, а также загрузка текстуры бэка и его размещения.А весь код работы с PageManage'ом занимает порядка 20-ти строк! Чудеса да и только!

Видео работы нашей демки:

Так что качаем первую (и далеко не последнюю) демонстрацию работы TPageManager'а или апдейтимся до 20ой ревизии в svn'е.

Сообщения, схожие по тематике:

4 коммент.:

  1. Неплохо :)
    Я в SpaceSim придумал немного другую идею, которую кратко обрисовал в этом посте:
    http://perfect-daemon.blogspot.com/2011/06/gamescreen.html

    (второй или третий абзац). К сожалению, придумывал такую систему в рамках конкурса (как следствие - она оказалась несовершенна), поэтому походу дела ее много пилил.

    Если кратко - то суть почти та же, разе что саму игру я тоже считаю элементом "меню".

    Жду новых статей :)

    ОтветитьУдалить
    Ответы
    1. супер!
      идея стопроцентно одинаковая! я тоже подсмотрел мысль на гд.ру, только совершенно в другом разделе и касаемо других вещей))..

      да, у меня страница - также призвана выполнять функционал, идентичный твоим GameScreen'ам! да, игра тоже одна из страниц, в моей терминологии это GamePage))..
      посмотрел твой код - высший пилотаж, очень все здорово! в рамках конкурса это вдвойне круто!

      у меня самый большой сдвиг мысли именно в сторону вложенности страниц друг в друга... немного не понял, как это выполняется у тебя, поэтому очень хотелось бы статью по использованию твоих GameScreen'ов!
      если у тебя будет время написать о том, как использовать и какие плюсы/минусы у всей системы, буду безумно благодарен!

      Удалить
    2. "у меня самый большой сдвиг мысли именно в сторону вложенности страниц друг в друга..."
      Вот тут прокол. У меня нет стека, есть активная страница и есть остальные, причем остальные могут быть как скрыты, так и просто в режиме паузы (не апдейтятся, но отображаются). Возможно, постараюсь переделать под стек, либо придумать иную систему, например что-то VCL-подобное (примером может являться движок Source с его окнами, поведение которых очень похоже на Windows-окна).

      С недавних пор я сторонник той идеи, что лучше сначала плохо сделать и переделать, чем сто лет проектировать идеальный вариант. Это касается, конечно, только кодинга.

      P.S. Заранее извиняюсь за ошибки, непонятки или пунктуацию — во мне много пива :)

      Удалить
    3. Насчет отсутствия стека - какой скрин тогда активизируется при деактивации текущего? Кто из них "последний"?
      По поводу "плохо сделать и переделать" - поддерживаю! Make games, not engines :)

      Удалить