material

Реализация адаптивного интерфейса

Теги: Статьи, Программирование, Игры

Всем привет, сегодня я расскажу об организации простого адаптивного интерфейса в своей игре. Примеры буду приводить на Delphi с использованием моего графического движка Perfect Engine.

Важность адаптивного дизайна

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

Что такое адаптивный дизайн? Это дизайн, подстраивающийся под некоторые выбранные разрешения экрана. В идеале, конечно, поддерживаемых разрешений должно быть как можно больше, но у нас не было необходимости в поддержке, например, мобильных устройств или старых мониторов с разрешением, меньшим 1024px в ширину. Поэтому, мы выбрали диапазон от 1024x720 до 1600x900 и приступили к работе.

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

Представим, что наше фоновое изображение имеет размер 800x600 - довольно мало на сегодня, и довольно много, если взглянуть на вес этого файла - около 400kb. Что же будет при запуске игры на экране в 1280px? Разумеется, фоновое изображение потеряет качество при масштабировании. Спросите, что с этим делать? Здесь вариантов не так много:

  • Ограничивать размер игрового окна, делая игру оконной, а не полноэкранной. Подойдет не везде.
  • Масштабировать изображение под разрешение экрана. Сильно потеряем в качестве.
  • Создать несколько изображений разных размеров и подставлять их в зависимости от разрешения, при необходимости растягивать. Самый лучший вариант, но требует большего места на жестком диске.

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

  • Адаптивность. Изображение в игровом меню будет масштабироваться в зависимости от разрешения экрана без потери качества.
  • Легкость. Графические файлы при таком подходе будут занимать не так много пространства на жестком диске, так как интерфейс будет состоять не из одного большого изображения, а из нескольких маленьких, заполняющих весь экран подобно сетке.

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

Интерфейс логической игры Crown

Разберем на части

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

Если подойти к этому вопросу более вдумчиво, то мы увидим, что, на самом деле, представленное выше изображение состоит всего из... пяти частей! Суммарный их вес на жестком диске - 65kb вместо 306kb, которые мы получим в отдельном изображении. Впечатляет, да? И это - при том, что качество его при масштабировании области вывода совершенно не пострадает.

Вот, как это происходит:

Игровой интерфейс глазами программиста

Здесь мы видим, что весь дизайн состоит из маленьких частей, последовательно заполняющих экран, как показывают стрелки. Вы можете заметить, что левый прямоугольник (сплошной цвет с шумом) можно было еще уменьшить, что дало бы дополнительный выигрыш в весе игры. Но это не так. Дело в том, что простые системы вывода (к которым относится и Perfect Engine) очень чувствительны к количеству выводимых на экран объектов, поэтому, если вы не хотите сильно загрузить процессор (если речь идет, например, о GDI), - стоит подумать об оптимальном выборе размера ваших изображений.

Работающий пример с исходным кодом я помещу в конце этой статьи.

С чего все начиналось

Я - веб-разработчик. Это мое основное занятие, занимаюсь дизайном и программированием сайтов. Поэтому, когда речь пошла о разработке компьютерной игры и системы адаптивного интерфейса для нее, первое, о чем я подумал, - была таблица стилей CSS. А ведь и правда, довольно часто в веб-разработке приходится сталкиваться с необходимостью адаптировать свой дизайн под различные разрешения экранов, и ничто не справится с этим лучше связки HTML + CSS.

К слову, в некоторых компьютерных программах я уже видел интерфейсы, построенные на HTML (или чем-то подобном). Рискую предположить, что, хорошо поискав, вы найдете несколько готовых решений для разбора HTML-кода и даже библиотек, организующих систему интерфейса с использованием этого языка. Возможно, найдете. Данная же статья направлена, прежде всего, на разъяснение основ этого метода, в результате чего красивый интерфейс можно будет реализовать даже на GDI.

Чего не может метод? Метод может все - при правильном подходе к его расширению. Но важно представлять себе его ограничения, связанные с используемой вами системой вывода. То есть, в CSS возможно практически все - от тени и свечения до сложных трансформаций, анимации и медиа-запросов для реализации адаптивности. И если вы имеете при себе хороший графический движок, позволяющий все это реализовать, то спокойно расширяйте метод, хотя, в этом случае, ваш движок, скорее всего, называется Unity или Unreal, и система интерфейса там уже реализована. =)

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

Хранение настроек

И, конечно же, у нас будет файл настроек. Это файл, содержащий в себе список всех элементов нашего интерфейса с набором необходимых параметров. Никакого разделения на разметку и стили - всего один ini-файл со всеми настройками. Ниже приведен их полный список:

  • Name - имя элемента для возможности его идентификации в программе и нестандартных действий с ним.
  • Page, Link - страница, на которой располагается элемент и ссылка на страницу, куда он ведет. Дело в том, что основная идея подобного подхода основана на все тех же взглядах со стороны веб-разработки. Все приложение - это набор страниц (Page), и каждый элемент внутри него может принадлежать одной из них и, в то же время, ссылаться на другую (Link).

При нажатии на такой ссылающийся элемент глобальная переменная 'Page' меняется на ту, что записана в параметре 'Link' элемента. А так как вывод на экран и взаимодействие осуществляются только для элементов, принадлежащих текущей странице, нажатие на такую ссылку скроет одни элементы и отобразит другие.

Итак, смотрим дальше:

  • Section - ссылка на уже созданный раздел параметров для другого элемента. Удобно, если основной дизайн всех страниц будет повторяться. Возможность присваивания одного элемента сразу нескольким страницам не проработана, и элемент создавать все равно придется. Но вместо многократного копирования настроек вы можете просто поставить ссылку на источник, и программа автоматически считает их оттуда.
  • Left, Right, Top, Bottom - границы элемента, указанные в процентах.

Идея метода основана на заполнении некоторой области изображениями. Если вы укажете:

Left = 0
Right = 100
Top = 0
Bottom = 100

то выбранное изображение равномерно заполнит всю область вывода. Если же значения Left и Right, а также, Top и Bottom, будут равны, - это позволит вам вывести на экран отдельное изображение (как изображение с логотипом игры на скриншоте выше).

  • Offset - тип смещения. Интересная настройка, решающая проблему позиционирования элемента по правому (Right = 100) или нижнему (Top = 100) краям области вывода. Координаты изображения при стандартном, общепринятом выводе, указывают позицию его левого верхнего пикселя. То есть, если мы захотим нарисовать изображение в правом нижнем углу экрана, простыми 'Left = 100; Top = 100' дело не обойдется, так как в этом случае оно уйдет за его границы, и мы его просто не увидим.

Настройка Offset позволяет сместить изображение относительно своей точки вывода, указывая для отображения:

  • 0 - стандартно
  • 1 - правый верхний пиксель
  • 2 - правый нижний
  • 3 - левый нижний
  • 4 - центр изображения

Таким образом, для позиционирования в правом нижнем углу экрана укажем:

Left = 100
Top = 100
Offset = 2

а для позициционирования с правой стороны и по центру (как надпись «Perfect Light» на скриншоте) -

Left = 100
Top = 50
Offset = 4
  • OffsetX, OffsetY - смещения в пикселях по одноименным координатным осям. Очень полезно при указании таких процентных значений. То есть, при выравнивании по центру наша надпись 'Perfect Light' будет наполовину уезжать за правую границу экрана. Установив отрицательный OffsetX, мы исправим эту проблему и добавим небольшой фиксированный отступ от правой границы экрана. Пиксельные значения очень полезны здесь своей статичностью - они не меняются в зависимости от разрешения экрана подобно процентным.
  • Image, AltImage - основное и альтернативное изображения элемента. Альтернативное изображение выводится в альтернативном состоянии, включающемся при наведении курсора на этот элемент. Так можно создавать графические кнопки.
  • FontName, FontSize, Color, HoverColor - настройки шрифта. Соответственно, имя, размер, основной цвет и цвет при наведении. Как и в случае с альтернативным изображением, HoverColor включается в альтернативном состоянии элемента и может использоваться для создания текстовых кнопок.
  • Text - текстовая строка. Метод не рассчитан на вывод длинных строк с переносами, но при должном желании может быть доработан. Думаю, подобным доработкам может быть посвящена отдельная статья, а пока Text - это простой короткий текст пункта меню, настройки в окне игровых параметров или игрового значения (количество жизней, бонусов и т.д.).

Вот и все настройки. =) Перейдем же к их программированию!

Программная реализация интерфейса

Интерфейс будет реализован в виде класса. Это к теме о важности структуризации - обернув свой алгоритм в класс, вы сможете без проблем использовать этот класс и в других проектах. Наш класс будет иметь несколько скрытых полей, соответствующих настройкам элемента, поле-событие onClick:

FOnClick: TEvent_deClick;

передающее имя элемента и номер страницы, на которую он ссылается, несколько скрытых и публичных функций и процедур:

Function fGetOffsetX(X, vWidth: Integer): Integer;
Function fGetOffsetY(Y, vHeight: Integer): Integer;
Function fIsMouseInside(): Boolean;
Procedure pLoadFromFile(vFile: TINIFile; vSection: String);

а также, набор свойств, необходимых для управления этим элементом:

Property Page: Byte Read FPage;
Property Link: Integer Read FLink;
Property Name: String Read FName;
Property Text: String Read FText Write pSetText;
Property IsAlternate: Boolean Read FIsAlternate Write FIsAlternate;
Property CursorPos: TPoint Read FCursorPos Write FCursorPos;

Private-методы класса

Давайте для начала рассмотрим скрытые функции класса.

Function TDesignElement.fGetOffsetX(X, vWidth: Integer): Integer;
begin
...Result := X - vWidth * Byte(FOffset = 1) - vWidth * Byte(FOffset = 2) -
......(vWidth div 2) * Byte(FOffset = 4);
...Result := Result + FOffsetX;
end;
Function TDesignElement.fGetOffsetY(Y, vHeight: Integer): Integer;
begin
...Result := Y - vHeight * Byte(FOffset = 2) - vHeight * Byte(FOffset = 3) -
......(vHeight div 2) * Byte(FOffset = 4);
...Result := Result + FOffsetY;
end;

Вычисление смещения элемента по координатным осям - сначала используя параметр Offset, и затем - пиксельное смещение. Выражение Byte(Offset = 2) дает 0 в случае ложного результата и 1 - в случае истинного. Напомню, что Offset = 2 - это смещение для вывода относительно правого нижнего пикселя изображения:

Смещения параметром Offset

То есть, Y - vHeight * Byte(FOffset = 2) означает «Отнять высоту элемента от его координаты по оси Y в том случае, если смещение установлено в 2». Все остальное сделано по аналогии - несколько простых условий для экономии места записано в виде одной строки.

Function TDesignElement.fIsMouseInside(): Boolean;
begin
...with FPixelRect do
......Result := (FCursorPos.X >= Left) and (FCursorPos.X <= Right) and
......(FCursorPos.Y >= Top) and
...(FCursorPos.Y <= Bottom);
end;

Функция, определяющая, попадает ли курсор внутрь элемента. Тоже ничего сложного, единственное, что здесь следовало бы учесть - это область FPixelRect. Содержит в себе пиксельные координаты элемента, вычисляемые при его загрузке и смене разрешения области вывода из процентных координат, заданных в файле (Left, Right, Top, Bottom). Все-таки, рисуем мы в пикселях, а не в процентах.

Ну а функцию pLoadFromFile мы разбирать, думаю, не будем - она занимается только загрузкой всех необходимых параметров из уже открытого INI-файла по переданному в нее имени раздела и в конце своей работы вызывает метод pUpdateдля расчета пиксельных координат элемента.

Конструктор класса мы, кстати, также, не будем рассматривать - он лишь вызывает pLoadFromFile и никакого интереса для нас не представляет.

Public-методы класса

Начнем с самого простого:

Function TDesignElement.fLMouseUp(): Boolean;
begin
...Result := fIsMouseInside() and (FLink > -3);
...if (Result) and (Assigned(FOnClick)) then onClick(FLink, FName);
end;

Этот метод вызывается из основной формы (или из общего игрового класса) при возникновении события MouseUp. Если курсор находится в пределах элемента, который, при этом, является ссылкой, то этот элемент вызывает событие onCLick, в котором программа будет менять текущую страницу и производить некоторые другие действия.

Здесь, думаю, уместно сказать и о некоторых дополнительных значениях параметра Link:

  • -2 - ссылка не установлена, событие onClick вызвано не будет. Используется для обыкновенных элементов оформления.
  • -1 - ссылка работает без смены страницы. Полезно, например, для реализации игровых настроек, когда нажатие на кнопку должно увеличить или уменьшить значение некоторой переменной, но страница меняться не будет. Кстати, все такие действия описываются в событии onClick внешнего класса, данный же класс является лишь удобным вспомогательным инструментом для их реализации.
  • 0 - ссылка ведет к завершению работы программы. Следовательно, отсчет страниц ведется с единицы.

Ну а теперь рассмотрим самые важные функции - обновления и вывода элемента на экран:

Procedure TDesignElement.pUpdate();
begin
...if (FOldScreenSize.X = vEngine.ScreenWidth) and (FOldScreenSize.Y = vEngine.ScreenHeight) then Exit;
//Расчет пиксельной области
...FPixelRect.Left := Round(FRect.Left * vEngine.ScreenWidth / 100);
...FPixelRect.Right := Round(FRect.Right * vEngine.ScreenWidth / 100);
...FPixelRect.Top := Round(FRect.Top * vEngine.ScreenHeight / 100);
...FPixelRect.Bottom := Round(FRect.Bottom * vEngine.ScreenHeight / 100);
...FOldScreenSize := Point(vEngine.ScreenWidth, vEngine.ScreenHeight);
//Параметры для проверки попадания курсора
...with FPixelRect do begin
......FRealSize := Point(Right - Left, Bottom - Top);
......if (FText <> '') then begin
.........FRealSize := vEngine.fGetTextSize(FText, FFont.Size);//Размер строки
.........BottomRight := Point(Left + FRealSize.X, Top + FRealSize.Y);
......end;
......if (FImage <> -1) and (FRealSize.X = 0) then FRealSize.X := vEngine.Texture[FImage].Width;
......if (FImage <> -1) and (FRealSize.Y = 0) then FRealSize.Y := vEngine.Texture[FImage].Height;
...end;
//Расчет смещения области вывода
...FPixelRect.Left := fGetOffsetX(FPixelRect.Left, FRealSize.X);
...FPixelRect.Right := fGetOffsetX(FPixelRect.Right, FRealSize.X);
...FPixelRect.Top := fGetOffsetY(FPixelRect.Top, FRealSize.Y);
...FPixelRect.Bottom := fGetOffsetY(FPixelRect.Bottom, FRealSize.Y);
end;

Функция обновления. Вызывается на каждом шаге игрового таймера или в некоторых определенных случаях (загрузка параметров из файла или смена некоторых свойств, требующих пересчета пиксельной области). Работает в том случае, если разрешение экрана было изменено (движок Perfect Engine дает необходимый доступ к этому значению), поэтому, при ее дополнительных вызовах значение FOldScreenSize предварительно устанавливается в «-1; -1», чтобы функция сработала. =)

Ну а внутри pUpdate все направлено исключительно на расчет пиксельной области, занимаемой элементом (FPixelRect). Притом, есть 3 возможных ситуации:

  • Элемент стандартно заполняется изображением. В таком случае FPixelRect просто примет пиксельные значения процентно заданных нами параметров.
  • Элемент содержит в себе текст. Здесь пиксельные границы будут соответствовать размеру текстовой строки, установленной для этого элемента.
  • Элемент имеет нулевые размеры (Left = Right или Top = Bottom). Эта ситуация означает, что мы собираемся вывести одно изображение без заполнения, и размеры пиксельной области будут равны размеру этого изображения.

Также, здесь рассчитывается некоторая переменная FRealSize, хранящая в себе размеры пиксельной области (в двух последних ситуациях эти размеры возвращает движок), и затем значение этой переменной используется для вычисления смещения элемента (fGetOffset).

Procedure TDesignElement.pDraw();
var
...i, j: Integer;
...vCurX, vCurY: Integer;//Координаты текущего прямоугольника
...vRows, vCols: Integer;//Количество строк и столбцов таблицы
...vCurImage: Integer;//Текущее изображение
...vTexSize: TPoint;//Размер текущей текстуры
...vFontColor: TAlphaColor;//Цвет текста
begin
//Предварительные вычисления
...vCurImage := FImage * Byte(FIsAlternate = False) + FAImage * Byte(FIsAlternate = True);
...if (vCurImage <> -1) then begin//Если изображение задано
......vTexSize.X := vEngine.Texture[vCurImage].Width;
......vTexSize.Y := vEngine.Texture[vCurImage].Height;
......vCols := (FPixelRect.Right - FPixelRect.Left) div vTexSize.X + 1;
......vRows := (FPixelRect.Bottom - FPixelRect.Top) div vTexSize.Y + 1;
...//Заполнение текстурой
......for i := 0 to vRows - 1 do begin
.........for j := 0 to vCols - 1 do begin
............vCurX := FPixelRect.Left + j * vTexSize.X;
............vCurY := FPixelRect.Top + i * vTexSize.Y;
............vEngine.pDrawTexture(vCurImage, vCurX, vCurY, vTexSize.X, vTexSize.Y, False);
.........end;
......end;
...end;
//Вывод текста
...if (FText = '') then Exit;//Текст не задан
...vEngine.pBindFont(FFont.Name);
...if (fIsMouseInside()) then vFontColor := FFont.HoverColor
...else vFontColor := FFont.Color;
...vTexSize := vEngine.fGetTextSize(FText, FFont.Size);
...vEngine.pTextOut(FText, FPixelRect.Left, FPixelRect.Top, FFont.Size, vFontColor);
end;

Думаю, самая интересная и сложная функция этого класса. Разбивает пиксельную область элемента на «ячейки» ((FPixelRect.Right - FPixelRect.Left) div vTexSize.X + 1), проходя по которым в последующем цикле, заполняет их изображением элемента. Это похоже на генерацию тайловой игровой карты, поэтому, думаю, разъяснять принцип такого заполнения подробнее необходимости нет.

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

Вот и весь класс. =)

Заключение

Думаю, на предыдущих строках вы облегченно вздохнули. Я сделал так же - писать такую длинную статью еще сложнее, чем потом ее читать.

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

Но в чем же его недостатки? Есть такие? Конечно, есть!

  • Недостаточная интерактивность, нет анимации. Это, конечно, сложно назвать недостатком - к каждой игре свои запросы, а метод, как уже было сказано выше, можно и доработать.
  • Нет возможности прокрутки, ограниченный диапазон разрешений, нет возможности менять свойства элементов в зависимости от разрешения экрана (медиа-запросы, как в CSS). Это серьезная проблема для игр с большим количеством настраиваемых параметров или для реализации справочного окна. При сильном уменьшении области вывода элементы начнут наезжать друг на друга, и здесь бы применить медиа-запрос для создания новой компоновки или, хотя бы, уменьшения шрифта, - всего этого здесь нет, но, разумеется, метод, опять же, может быть дополнен.
  • Не работает с длинными текстами, не умеет переносить строки. Но и это, опять же, задача расширения.

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

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

responsive.zip
Скачать размер: 327kb

Тестовая программа с исходным кодом на языке Pascal, демонстрирующая метод построения адаптивного интерфейса, описанный в статье.

автор: SaiLight работа над проектом: 2016 год

Связанные проекты:

выложено уже 20 проектов из 124 созданных команда Perfect Light, 2017 год