Главная » Программирование игр под Android » КОД: СКУЧНАЯ РУТИНА – РАЗРАБОТКА ИГР ДЛЯ ОС ANDROID

0

 

Итак, вы уже знаете, что Android API подходит для разработки игр. Но вам все еще неизвестно, как именно это делать. Уже есть идеи по дизайну игры, однако процесс преобразования его в исполняемый файл пока выглядит неким колдовством. В следующих подразделах я собираюсь сделать для вас обзор составляющих компьютерной игры. Я использую немного псевдокода для интерфейсов, которые мы реализуем позже с помощью Android. Интерфейсы – очень полезная штука по двум причинам: они позволяют нам сконцентрироваться на семантике, не отвлекаясь на детали реализации, а также дают возможность при необходимости менять способ этой реализации (например, вместо использования 2В-визуализации мы для демонстрации мистера Нома можем задействовать OpenGL ES).

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

Управление окнами. Отвечает за создание окна и берет на себя такие вещи, как его закрытие или приостановка/возобновление работы приложения на Android.

Ввод. Этот модуль связан с предыдущим и отвечает за отслеживание пользовательского ввода (касаний, клавиатурного ввода и движений акселерометра).

Файловый ввод/вывод. Позволяет приложению получать ресурсы, расположенных на носителе.

Графика. Пожалуй, наиболее сложная часть, если не считать собственно игру. Этот модуль ответственен за загрузку графики и прорисовку ее на экране.

Звук. Загрузка и воспроизведение всего, что способно достичь наших ушей.

Игровой фреймворк. Соединяет в себе все вышеперечисленное и предлагает удобную основу для написания игр.

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

ПРИМЕЧАНИЕ

Вы не ошиблись – я намеренно не включил поддержку сети в данный список. Мы не будем рассматривать в е вопросы создания многопользовательских игр. Это весьма сложный вопрос, ответ на который сильно зависит от типа игры. Если вам интересна данная тема, в Сети вы сможете найти много соответствующей информации (www.gamedev.net – прекрасное место для старта).

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

Управление приложением и окнами

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

Большинство ОС позволяют пользователям взаимодействовать с окнами особым образом (не считая касания пользовательской области или нажатия кнопки). На компьютерных системах вы обычно можете перетаскивать их, изменять их размер и сворачивать в каком-либо варианте Панели задач. В случае с Android изменение размера заменено на смену ориентации, а сворачивание реализовано в виде перехода приложения в фоновый режим при нажатии кнопки Ноте (Домой) или входящем звонке.

Модуль управления приложением и окнами решает, кроме того, задачу настройки окна и обеспечения заполнения его компонентом интерфейса, воспринимающим пользовательский ввод в виде касаний и нажатий клавиш. Этот компонент может быть визуализирован либо центральным процессором, либо с помощью аппаратного ускорения (как в случае с OpenGL ES).

Модуль управления приложением и окнами не обладает каким-то конкретным набором интерфейсов. Мы объединим его с игровым фреймворком позже. Сейчас нам необходимо запомнить состояния приложения и события окна, которыми необходимо управлять:

Create (Создать) – возникает один раз при открытии окна (а следовательно, и приложения);

Pause (Пауза) – появляется при приостановке работы приложения каким-либо способом;

Resume (Возобновить) – возникает при возобновлении работы приложения и возвращения окна на передний план.

ПРИМЕЧАНИЕ

Некоторые поклонники Android могут в этот момент округлить глаза. Почему используется только одно окно (активность на языке Android)? Почему не применять для игры более одного виджета, чтобы создавать сложные пользовательские интерфейсы? Главным образом потому, что нам необходим полный контроль над внешним видом и ощущением от игры. Кроме того, в этом случае я могу сосредоточиться на программировании игры для Android вместо программирования интерфейсов для Android. О данной теме вы можете почитать в других источниках.

Ввод

Конечно, пользователю необходимо будет взаимодействовать с игрой каким-либо образом. Именно этим занимается модуль ввода. В большинстве операционных систем события ввода (вроде касания экрана или нажатия кнопки) отсылаются к текущему окну. Далее это окно перенаправляет данные события элементу интерфейса, на котором в данный момент находится фокус. Процесс перенаправления обычно весьма прозрачен для нас. Все, что нам необходимо, – получать события от сфокусированного компонента интерфейса. API пользовательского интерфейса операционной системы предлагает механизм внедрения в систему перенаправления событий, чтобы мы могли их легко регистрировать и сохранять. Но что нам делать с записанной информацией? Существует два варианта действий.

Опрашивание. При данном способе мы лишь проверяем текущее состояние устройства ввода. Любые изменения состояния между опросами будут для нас потеряны. Данный вид обработки ввода подходит для таких вещей, как проверка факта нажатия пользователем определенной кнопки. Он не годится для отслеживания ввода текста, ведь порядок ввода символов будет утерян.

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

Какие устройства ввода нам нужно контролировать? В случае с Android существует три основных метода ввода: сенсорный экран, клавиатура/трекбол и акселерометр. Для первых двух способов могут использоваться оба метода обработки событий. Для акселерометра обычно применяется только опрашивание. Сенсорный экран способен генерировать три события: касание экрана – происходит, когда палец касается дисплея; перетаскивание – выполняется, когда палец движется по дисплею. Возникновению этого события всегда предшествует событие касания; отрыв – происходит, когда палец поднимается от дисплея.

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

Клавиатура генерирует два типа событий: клавиша нажата – происходит при нажатии кнопки; клавиша отпущена – выполняется при поднятии пальца от клавиши.

Клавиатурные события также содержат дополнительные данные. События типа клавиша нажата хранят код нажатой кнопки, а события типа клавиша отпущена включают в себя код кнопки и Юникод-символ. Между ними существует разница: во втором случае учитывается также состояние других клавиш (например, клавиши Shift). Благодаря этому у нас появляется возможность определять, ввел пользователь большую или маленькую букву. С событием нажатия клавиши нам повезло меньше – у нас нет точных данных о том, какой именно символ создается.

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

Рис. 3.19. Оси акселерометра на телефоне Android; ось z указывает вверх над телефоном

Ускорение по каждой из осей измеряется в метрах в секунду за секунду (м/с2). Из уроков физики вы можете помнить, что при свободном падении любой объект движется с ускорением 9,8 м/с2. На других планетах значение ускорения свободного падения отличается. Для простоты будем считать, что наше приложение будет работать только на планете Земля. Когда точка на оси отдаляется от центра Земли, значение ускорения будет возрастать. При обратном перемещении мы получаем отрицательную динамику ускорения. Например, если вы держите телефон вертикально в портретном режиме, значение на оси у будет равно 9,8 м/с2. На рис. 3.19 такое значение будет по оси г, а оси хиу будут сообщать ускорение, равное нулю.

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

Листинг 3.1. Интерфейс Input и классы KeyEvent и TouchEvent

Наше определение начинается с двух классов – KeyEvent и TouchEvent. Класс KeyEvent определяет константы, кодирующие тип KeyEvent; класс TouchEvent делает то же самое. Экземпляр KeyEvent хранит его тип, код клавиши и Юникод-код (если тип события KEY UP).

Код TouchEvent выполняет аналогичную функцию – хранит тип TouchEvent, позицию пальца относительно исходного элемента интерфейса, и ID указателя, выданный данному пальцу драйвером сенсорного экрана. Этот ID будет храниться до тех пор, пока палец будет касаться дисплея. При этом первый коснувшийся экрана палец получает ID, равный 0, следующий – 1 и т. д. Если экрана касаются два пальца и палец 0 поднят, ID второго остается равным 1 до тех пор, пока он касается экрана. Следующий палец получает первый свободный номер, который в данном случае может быть равен 0.

Следующие строки кода – методы опрашивания интерфейса Input, которые достаточно прозрачны и не требуют подробных объяснений. Input.isKeyPressedO получает keyCode и возвращает результат – нажата соответствующая кнопка в данный момент или нет. Input.isTouchDownO, Input.getTouchXO и Input.getTouchYO возвращают состояние переданного им указателя, его х- и г-координаты. Обратите внимание – значение этих координат будет не определено, если соответствующий указатель в данный момент не касается экрана.

Input. getAccel Х, Input .getAccel Y и Input.getAccelZO возвращают соответствующие значения ускорения для каждой оси.

Последние два метода используются для реализации обработчиков событий. Они возвращают экземпляры KeyEvent и TouchEvent, хранящие информацию с последнего раза, когда мы вызывали эти методы. События расположены в порядке их появления – самое свежее из них размещено в конце списка.

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

ПРИМЕЧАНИЕ

Хотя изменяющиеся классы с открытыми методами вызывают отвращение, в данном случае мы можем их оставить по двум причинам: во-первых, Dalvik все еще слишком нетороплив при вызове методов (в данном случае свойств); во-вторых, изменяемость классов событий не влияет на внутреннюю работу нашей реализации ввода. Просто запомните, что это – плохой стиль программирования, и больше не будем об этом вспоминать.

Файловый ввод-вывод

Чтение и запись файлов – весьма необходимые вещи для наших попыток в программировании игр. Учитывая, что мы находимся в стране Java, по большей части нам придется иметь дело с экземплярами классов InputStream и OutputStream – стандартными Java-механизмами чтения и записи данных в файлы. В нашем случае нас больше интересует чтение файлов из пакета нашей игры (уровней, изображений, звукозаписей). С записью файлов мы будем сталкиваться гораздо реже – она понадобится нам, только если мы захотим сохранить результаты, настройки или игру, чтобы потом продолжить с того места, где прервались. В общем, нам необходим самый простой механизм доступа к файловой системе – такой, как в листинге 3.2.

Листинг 3.2. Интерфейс FilelO

Тут все просто и понятно. Мы просто определяем имя файла и возвращаем для него поток. Как обычно, в Java в случае непредвиденных событий мы вызываем исключение IOExcepti on. Где именно мы будем читать и записывать файлы, зависит, конечно, от реализации интерфейса. Ресурсы могут быть прочитаны непосредственно из АРК-файла приложения или с SD-карты (также называемой внешним хранилищем).

Возвращаемые экземпляры InputStreams и OutputStreams – старые добрые потоки Java. Естественно, после окончания использования их необходимо закрывать.

Звук

Хотя программирование звука – довольно сложная тема, мы можем использовать для него весьма простую абстракцию. Мы не будем реализовывать сложную обработку звука; достаточно будет обеспечить воспроизведение звуковых эффектов и музыки, загруженных из файлов (примерно так же, как мы будем загружать растровые изображения в графическом модуле).

Однако перед тем, как погрузиться в интерфейсы звукового модуля, сделаем паузу и получим некоторое представление о том, что такое звук и как его представить в цифровом виде.

Физика звука

Звук обычно описывают как поток волн, перемещающихся в пространстве подобно воздуху или воде. Волна – это не физически осязаемый объект, а скорее движение молекул в пространстве. Представьте себе пруд, в который бросили камень. Когда камень достигает поверхности воды, он заставляет двигаться множество молекул, которые, в свою очередь, передают свое движение соседям. В результате вы увидите круги, расходящиеся от того места, куда упал камень. Из подобных высоконаучных экспериментов, проводимых вами в детстве, вы знаете, что волны воды могут взаимодействовать друг с другом – складываться или гасить друг друга. Все это верно и для звуковых волн. Они комбинируются для получения разных тонов и мелодий, которые вы слышите как музыку. Громкость звука определяется количеством энергии, передаваемой молекулами своим соседям, пока они не достигнут вашего уха.

Запись и воспроизведение

Принцип записи и воспроизведения звука в теории довольно прост: для записи мы отслеживаем количество энергии, приложенной к определенной точке молекулами, формирующими звуковые волны. Воспроизведение – по сути, принуждение молекул воздуха, окружающих динамик, двигаться так же, как они делали это при записи.

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

При цифровой записи звука состояние мембраны микрофона измеряется за дискретный временной такт и сохраняется. В зависимости от состояния окружающих молекул мембрана может выгибаться внутрь или наружу, возвращаясь затем в нейтральное состояние. Процесс измерения и сохранения состояния микрофона называется сэмплированием, поскольку мы сохраняем сэмплы состояния мембраны за дискретные такты времени. Количество полученных сэмплов за единицу времени называется частотой дискретизации. Обычно временной интервал задается в секундах, а частота измеряется в герцах (Гц). Чем больше сэмплов записывается в секунду, тем выше качество записи. Компакт-диски воспроизводят звук с частотой дискретизации 44 100 Гц (44,1 КГц). Более низкие частоты дискретизации можно обнаружить при передаче голоса по телефону (чаще всего 8 КГц).

Частота дискретизации – отнюдь не единственный параметр, определяющий качество звука. Способ хранения положений мембраны также имеет значение и тоже является субъектом оцифровки. Напомню, что такое положение мембраны – это размер ее отклонения от нейтрального положения. Поскольку направление отклонения имеет значение, его значение сохраняется со знаком. Следовательно, положение мембраны во время определенного временного интервала – положительное или отрицательное число. Мы можем хранить это число разными способами: как целое 8-, 16- или 32-битное число со знаком или как 32-битное (и даже 64-битное) число с плавающей точкой. Каждый из этих типов данных имеет свою точность. Например, 8-битное целое число может хранить значения от -128 до 127.32-битный тип i nteger предлагает намного больший диапазон. При хранении в виде f 1 oat положение мембраны обычно нормализируется, чтобы попадать в диапазон от -1 до 1. При этом максимальное и минимальное значения соответствуют наибольшему отклонению мембраны от нейтрального положения в обе стороны. Положение мембраны также называется амплитудой. Оно характеризует громкость записываемого звука.

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

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

Качество звука и компрессия

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

Представьте, что мы записали один и тот же аудиоролик длиной 60 секунд дважды: в первый раз с частотой дискретизации 8 КГц и 8 битами на сэмпл и во второй раз с частотой 44 КГц и 16 битами на сэмпл. Как вы думаете, сколько места потребуется нам для хранения в обоих случаях? Для первого ролика мы используем 1 байт на сэмпл. Умножим это значение на частоту 8000 Гц и получим 8000 байт в секунду. Для 60-секундной звукозаписи нам потребуется 480 000 байт, или примерно 0,5 Мбайт. Вторая, высококачественная запись потребует больше места: 2 байта на сэмпл, 2 раза по 44 000 байт в секунду. Итого 88 000 байт в секунду. Умножаем на 60 секунд и получаем в итоге 5 280 000 байт (чуть более 5 Мбайт). Стандартная трехминутная поп-песенка в таком качестве займет примерно 15 Мбайт (и это мы говорим о режиме моно, для стереокачества места потребуется в два раза больше). Не многовато ли места для глупой песенки?

Умные люди позаботились об этом и разработали различные способы уменьшить количество байт, необходимых для записи звука. Они изобрели довольно сложные психоакустические алгоритмы компрессии, анализирующие исходные записи и дающие на выходе их сжатые версии. Этому процессу обычно сопутствуют потери – некоторые несущественные части оригинальной записи удаляются. При использовании таких форматов, как МРЗ или OGG, вы на самом деле слушаете сжатые аудиозаписи с потерями, однако их применение помогает уменьшить количество требуемого дискового пространства.

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

 

На практике

Вы убедились, что даже трехминутные песенки могут занять много места. Когда мы воспроизводим музыку из нашей игры, то извлекаем звуковые фрагменты на лету, вместо того чтобы загружать их заранее в память. Обычно у нас воспроизводится только один звуковой поток за раз, поэтому нам необходим только однократный доступ к диску. Для коротких звуковых эффектов (например, взрывов или выстрелов) картина несколько иная – один и тот же звуковой эффект мы будем применять неоднократно и одновременно. Потоковое воспроизведение звуковых фрагментов с диска для каждого экземпляра эффекта – не лучшее решение.

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

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

Все это напрямую транслируется в интерфейсы Audi о, Musi с и Sound (показанные в листингах 3.3-3.5 соответственно).

Листинг 3.3. Интерфейс Audio

Интерфейс Audio – наш способ создавать новые интерфейсы Music и Sound. Экземпляр Musi с представляет потоковый аудиофайл, интерфейс Sound – короткий звуковой эффект, который мы можем хранить в памяти. Методы Audio.newMusicO и Audi о. newSound  принимают имя файла в качестве аргумента и вызывают исключение IOExcepti on при сбое процесса загрузки (например, если нужный файл не существует или поврежден). Имена файлов соотносятся с файлами активов, хранящимися в пакете АРК нашего приложения.

Листинг 3.4. Интерфейс Music

Интерфейс Music чуть более сложен. Он включает в себя методы для начала воспроизведения музыкального потока, приостановки и прекращения воспроизведения, а также циклического воспроизведения (начало проигрывания звука с начала сразу после окончания). Кроме того, мы можем установить громкость в виде типа данных float в диапазоне от 0 (тишина) до 1 (максимум). Интерфейс также содержит несколько методов получения, позволяющих отследить текущее состояние экземпляра Music. После того как необходимость в объекте Music отпадет, его необходимо утилизировать – это освободит системные ресурсы, а также снимет блокировку с воспроизводимого звукового файла.

Листинг 3.5. Интерфейс Sound

Интерфейс Sound, напротив, очень прост. Все, что нам нужно, – вызов метода play, принимающего в качестве параметра громкость в виде числа Я oat. Мы можем вызывать метод pi ау  всякий раз, когда захотим (например, при каждом выстреле или прыжке персонажа). Когда необходимость в экземпляре Sound отпадет, его следует уничтожить по тем же причинам – для освобождения памяти и потенциально связанных системных ресурсов.

ПРИМЕЧАНИЕ

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

Графика

Последний большой модуль, который нам необходимо изучить, – графика. Как вы, вероятно, догадались, он отвечает за прорисовку изображений (также известных как растры) на нашем экране. Это может звучать просто, но если вы хотите использовать высокопроизводительную графику, то должны познакомиться для начала с основами работы с графикой. Начнем со старого доброго 2D.

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

О растрах, пикселах и фреймбуферах

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

У пиксела есть два атрибута: позиция в таблице и цвет. Позиция пиксела выражается двухмерными координатами в дискретной координатной системе. Дискретность в данном случае означает, что каждая координата выражена целым числом. Вообще, пикселы определяются в виде Эвклидовой координатной системы, наложенной на таблицу и начинающейся из левого верхнего угла. Значения координаты х растут слева направо, координаты у – сверху вниз. Последнее обстоятельство часто сбивает с толку. Но на это есть простая причина, и скоро мы о ней поговорим.

Игнорируя ось у, мы увидим, что из-за дискретной природы координат их начало совпадает с левым верхним углом таблицы, расположенным по адресу (0; 0). Пиксел справа от него имеет координаты (1; 0), пиксел под ним – (0; 1) и т. д. (посмотрите на левую часть рис. 3.20). Растровая таблица дисплея небезгранична, поэтому количество координат ограниченно. Координаты с отрицательными значениями находятся за пределами экрана (как и координаты, равные и превышающие границы растра). Обратите внимание – максимальное значение координаты х равно ширине таблицы минус 1, а максимальной координаты у – высота минус 1. Это происходит из-за того, что координаты начинаются с нуля, а не единицы (распространенная причина недоразумений при программировании графики).

Рис. 3.20. Упрощенная схема таблицы растров и VRAM

Дисплей получает постоянный поток информации от графического процесса. Он кодирует цвет каждого пиксела экранной таблицы, как это определяется программой или операционной системой при управлении прорисовкой дисплея. Экран обновляет свое состояние несколько десятков раз в секунду (это значение называется частотой обновления, выражаемой в герцах). Частота обновления ЖК-дисплеев составляет обычно 60 Гц в секунду; ЭЛТ-мониторы и плазменные панели поддерживают большую частоту.

Графический процессор имеет доступ к специальной области памяти, также известной как видеопамять (или VRAM). Внутри VRAM, в свою очередь, зарезервирована область для хранения каждого пиксела, отображаемого на экране.

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

Пришло время объяснить, почему ось у в системе координат дисплея направлена вниз. Память (будь это VRAM или обычная RAM) линейна и однонаправлена. Представляйте ее в виде одномерного массива. Как же нам разместить двухмерные координаты пиксела в одномерной ячейке памяти? На рис. 3.20 показана весьма маленькая таблица 3×2 пиксела, а также ее представление в VRAM (представим, что VRAM состоит только из фреймбуфера). Исходя из него, мы можем вывести формулу вычисления адреса памяти для пиксела с координатами х и у:

Мы можем также пойти другим путем, рассчитав отдельно координаты х и у пиксела:

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

ПРИМЕЧАНИЕ

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

Vsync и двойная буферизация

Итак, значения частот обновления не слишком велики, и мы можем записывать данные во фреймбуфер быстрее, чем обновляется экран. Такое вполне может произойти. Более того – мы не знаем точно, когда дисплей получает последнюю копию фрейма из VRAM, что может вызвать проблемы, если мы находимся в процессе рисования чего-нибудь. В этом случае дисплей покажет нам части старого содержимого фреймбуфера вперемешку с новыми, что крайне нежелательно. Вы можете наблюдать данный эффект во многих играх для PC, где его можно описать как размытость (на экране одновременно видны части старого и нового кадра).

Первая часть решения этой проблемы – так называемая двойная буферизация. Вместо того чтобы использовать единственный фреймбуфер, графический процессор (GPU) управляет двумя – основным и фоновым. Из основного буфера дисплей получает информацию о цветах пикселов. Фоновый буфер нужен для прорисовки следующего кадра, пока экран переваривает содержимое основного буфера. После прорисовки текущего кадра мы приказываем GPU поменять эти буферы местами, что обычно означает команду поменять местами их адреса в памяти. В литературе по программированию графики и документации по API вы можете обнаружить такие термины, как переворот страницы и обмен буферов, как раз и обозначающие описанный процесс.

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

К счастью, сейчас нам редко приходится заботиться об этих нудных подробностях. VRAM и тонкости двойной буферизации и вертикальной синхронизации безопасно скрыты от нас, поэтому мы не можем натворить с ними дел. Вместо этого нам предлагается набор API, обычно ограничивающий нас в манипуляциях содержимым нашего окна приложения. Некоторые из этих API (например, OpenGL ES) используют аппаратное ускорение, а это обычно подразумевает лишь манипулирование VRAM с помощью специальных контуров графического чипа. Так что, как видите, никакого волшебства. Причина, по которой вы должны понимать принципы работы графики (по крайней мере на высшем уровне), состоит в том, что это позволяет вам понимать законы, управляющие производительностью вашего приложения. При включенном vsync вы никогда не сможете превысить частоту обновления вашего дисплея, что может сбить с толку, если вы хотите нарисовать один-единственный пиксел.

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

Что такое цвет?

Вы, вероятно, заметили, что до сей поры я игнорировал разговоры о цветах. Я создал тип col or на рис. 3.20 и притворился, что все в порядке. Теперь пришло время узнать, что такое цвет на самом деле.

С точки зрения физики цвет – реакция сетчатки глаза и коры головного мозга на электромагнитные волны. Такая волна характеризуется длиной и интенсивностью. В пределах нашей видимости находятся волны длиной от 400 до 700 нм. Этот сегмент электромагнитного спектра, также известный как видимая часть спектра, заключается между фиолетовым и красным цветами (между ними находятся синий, желтый и оранжевый). Передача цветом монитора заключается в трансляции электромагнитных волн определенной частоты для каждого пиксела. Разные виды дисплеев используют различные способы достижения этой цели. Упрощенно этот процесс можно представить так: пиксел на экране состоит из трех разных светящихся частей, каждая из которых излучает один из цветов (красный, синий и зеленый). При обновлении дисплея эти части светятся по разным причинам (например, в ЭЛТ-мониторах в них попадают электроны). При этом дисплей контролирует, какие части из набора светятся. Если пиксел полностью красный, только красная его часть будет бомбардироваться электронами с полной интенсивностью. В случае, когда нам нужны цвета, отличные от базовых, это достигается смешиванием, которое, в свою очередь, реализуется управлением интенсивностью свечения частей пиксела. Электромагнитные волны по пути к сетчатке нашего глаза накладываются друг на друга, и наш мозг интерпретирует эту смесь как определенный цвет. Поэтому мы можем определить любой цвет как смешение трех базовых цветов разной интенсивности.

Цветовые модели

То, что мы обсуждали только что, называется цветовой моделью (точнее, цветовой моделью RGB). RGB означает Red (Красный), Green (Зеленый), Blue (Синий). Существует множество и других цветовых моделей (например, YUV и CMYK). Однако в большинстве API для программирования модель RGB является, по сути, стандартом, поэтому и мы будем обсуждать только ее.

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

Конечно, мы можем создавать гораздо больше цветов, чем показано на рис. 3.21, варьируя интенсивность красного, синего и зеленого цветов. Каждый компонент обладает интенсивностью от 0 до максимального значения (допустим, 1). Если мы интерпретируем каждый компонент цвета как значение одной из трех осей Эвклидовой оси координат, то сможем построить так называемый цветовой куб (рис. 3.22). При изменении интенсивности каждого компонента можно получить еще больше цветов. Цвет рассматривается как триплет (красный, зеленый, синий), где значение каждого элемента лежит в диапазоне от 0 до 1. Например, 0,0 означает отсутствие интенсивности для данного цвета, а 1,0 – полную интенсивность. Черный цвет имеет значение (0; 0; 0), белый – (1; 1; 1).

Рис. 3.21. Смешивание базовых цветов

Рис. 3.22. Цветовой куб

Цифровое кодирование цветов

Как мы можем закодировать триплет цветов RGB в компьютерной памяти? Для начала необходимо определиться с типом данных, который мы будем использовать для цветовых компонентов. Можно воспользоваться числами с плавающей точкой и определить диапазон между 0,0 и 1,0. Это дало бы нам большое разнообразие вариантов цветов. К сожалению, данный способ требует много места (3 раза по 4 или 8 байт на пиксел в зависимости от того, используем ли мы 32- или 64-битные числа). Поэтому лучшим решением будет отказаться от всего возможного многообразия цветов, тем более что дисплеи обычно способны передавать ограниченное их количество. Вместо чисел с плавающей точкой мы можем применить беззнаковые целые числа. Интенсивность каждого компонента варьируется от 0 до 255. В этом случае для одного пиксела нам потребуется лишь 3 байта, или 24 бита, а значит, мы сможем закодировать таким образом 2 в 24-й степени (16 777 216) цветов. Я бы сказал, что этого вполне достаточно.

Можем ли мы сэкономить еще? Да, можем. Мы можем запаковать каждый компонент в отдельное 16-битное слово, и в этом случае каждому пикселу потребуется 2 байта для хранения. Красному цвету будет необходимо 5 бит, зеленому – 6, синий будет использовать оставшиеся 5 бит. Причина, по которой зеленому цвету нужно больше бит, в том, что наши глаза распознают больше оттенков зеленого, чем синего или красного. Все биты вместе дают 2 в 16-й степени (65 536) вариантов возможных цветов. На рис. 3.23 показаны три описанных выше способа кодирования цвета.

Рис. 3.23. Цветовое кодирование оттенка розового (на черно-белом рисунке он, увы, выглядит серым)

В случае с типом float мы можем использовать 32-битный тип данных Java. При 24-битном кодировании у нас возникает небольшая проблема: в Java нет 24-битного типа integer, поэтому нам либо придется хранить каждый компонент в типе byte, либо задействовать 32-битный тип integer, оставляя 8 его бит неиспользованными. При 16-битном кодировании мы можем либо применять два отдельных байта, либо хранить компоненты в данных типа short. Заметьте также, что Java не имеет беззнаковых типов данных, однако мы можем спокойно использовать знаковые типы для хранения беззнаковых значений.

Как при 16-битном, так и при 24-битном кодировании необходимо также определить порядок, в котором будут храниться три цветовых компонента в типе данных short или integer. Обычно используются два варианта: RGB и BGR. На рис. 3.23 используется RGB-кодирование. Синий компонент хранится в нижних 5 или 8 битах, зеленый компонент – в следующих 6 или 8 битах, красный – в верхних 5 или 8 битах. При BGR-кодировании порядок обратный – зеленый остается на месте, красный и синий меняются местами. В е мы будем пользоваться RGB-порядком, поскольку в графическом API Android применяют именно его.

Итак, подведем итоги нашей дискуссии о кодировании цветов. О 32-битное RGB-кодирование f1oat использует 12 байт на пиксел, интенсивность при этом меняется в диапазоне от 0,0 до 1,0. О 24-битное RGB-кодирование 1 nteger применяет 3 или 4 байта на пиксел, интенсивность при этом варьируется от 0 до 255. Порядок компонентов может быть RGB или BGR. Иногда используется маркировка RGB888 или BGR888, где цифра 8 означает количество битов на компонент.

16-битное RGB-кодирование Integer применяет 2 байта на пиксел; интенсивность красного и синего цветов меняется от 0 до 31, зеленого – от 0 до 63. Порядок компонентов может быть RGB или BGR. Иногда используется маркировка RGB565 или BGR565, где цифры 5 и 6 означают количество битов на соответствующий компонент.

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

ПРИМЕЧАНИЕ

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

Форматы изображения и сжатие

На определенном этапе процесса разработки игры наш художник предложит нам изображения, созданные с помощью специального программного обеспечения (например, Gimp, Pain.NET или Photoshop). Эти изображения могут храниться на диске в различных форматах. Для чего такое разнообразие? Разве мы не можем хранить растровые картинки на диске как простой набор байтов?

В общем-то, да, можем. Однако посмотрим, сколько это займет места. Если нам необходимо лучшее качество, то пикселы будут кодироваться в RGB888 (24 бита на пиксел). Допустим, размер изображения составляет 1024 х 1024 пиксела. Итого – 3 Мбайт на одну небольшую картинку. При использовании метода RGB565 вы сэкономите, но немного – размер уменьшится на 1Мбайт.

Как и в случае со звуком, над методами уменьшения объема необходимой для хранения изображения памяти была проведена большая работа. Были разработаны алгоритмы компрессии, предназначенные для хранения изображений и сохраняющие как можно больше информации о цвете. Два наиболее популярных формата -JPEG и PNG. JPEG – формат с потерей качества. Это означает, что некоторая часть исходных данных в процессе сжатия теряется, однако он предлагает большую степень сжатия и таким образом экономит больше места на диске. PNG относится к числу форматов без потери данных, поэтому он воспроизводит изображение со стопроцентной точностью. Выбор формата зависит, таким образом, преимущественно от ограничений емкости хранилища.

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

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

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

Альфа-наложение и смешивание

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

Рис. 3.24. Простой фон и Боб, хозяин Вселенной

Чтобы нарисовать мир Боба, для начала поместим фон во фреймбуфер, а потом туда же запустим поверх него нашего героя. Порядок действий важен, ведь вторая картинка перепишет текущее содержимое фреймбуфера. Итак, что мы получим в итоге? Ответ на рис. 3.25.

Рис. 3.25. Наложение Боба на фон во фреймбуфере

Это явно не то, что нам было нужно. Обратите внимание на рис. 3.24 – Боб окружен белыми пикселами. Когда мы помещаем его поверх фона во фреймбуфер, эти белые пикселы тоже там оказываются. Что нам надо сделать, чтобы Боб появился на фоне один, без сопутствующего ему белого фона?

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

Ранее я упоминал, что мы можем хранить 24-битный RGB-триплет в 32-битном типе данных integer. В этом случае у нас остается 8 неиспользованных бит, которые можно использовать для хранения значения альфы. Полупрозрачность пиксела может в данном случае варьироваться от 0 (полная прозрачность) до 255 (непрозрачность). Такой способ кодирования известен как ARGB8888 или BGRA8888 (в зависимости от порядка). Конечно, существуют также форматы RGBA8888 и ABGR8888.

При использовании 16-битного кодирования мы сталкиваемся с небольшой проблемой – все биты типа данных short заняты цветовыми компонентами. Определим некий формат ARGB4444 (по аналогии с ARGB8888), в котором 12 бит будут отведены под RGB-значения (по 4 бита на элемент).

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

Рассматривая смешивание с формальной точки зрения, нам нужно определить несколько моментов: у смешивания есть два входа и один выход, каждый из которых представлен в виде RGB-триплета и альфа-значения (а); два входа называются источником и целью. Источник – это пиксел изображения, который мы хотим нарисовать поверх цели (например, во фреймбуфере). Цель – пиксел, который мы хотим частично перерисовать источником; выход – это тоже цвет, определяемый RGB-триплетом и альфа-значением. Обычно мы игнорируем альфу, для простоты в этой главе сделаем так же; чтобы слегка упростить нашу математику, представим значения RGB-компонента и альфа в диапазоне от 0,0 до 1,0.

Вооружившись этими определениями, мы можем создать так называемые уравнения смешивания. Простейшее из них выглядит примерно так:

src и dst – пикселы источника и цели, которые мы хотим объединить. Два цвета смешиваются покомпонентно. Обратите внимание на отсутствие в этих уравнениях альфа-значения целевого пиксела. Попробуем это на примере:

Рисунок 3.26 иллюстрирует предыдущее уравнение. Наш источник окрашен в оттенок розового, цель – в оттенок зеленого. Оба они участвуют в создании итогового цвета (некоего оттенка оливкового).

Рис. 3.26. Смешивание двух пикселов

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

ПРИМЕЧАНИЕ

Смешивание – широкое поле для деятельности. Если вы хотите использовать весь его потенциал, можете поискать в Сети работы Портера и Даффа на эту тему. Однако для игр, которые мы будем писать, достаточно будет самого простого уравнения.

Обратите внимание на большое количество операций умножения, участвующих в вышеупомянутых уравнениях (шесть, если быть точным). Умножение – ресурсоемкая операция, и ее необходимо по возможности избегать. При смешивании мы можем избавиться от трех операций умножения, предварительно умножив RGB-значения пиксела-источника на его же альфа-значение. Большинство программ по обработке графики поддерживают предварительное умножение значений RGB на соответствующее значение альфы. Если поддержка таких операций отсутствует, вы можете сделать это в процессе загрузки картинки в память. Однако при использовании графического API для прорисовки изображений со смешиванием, нам необходимо будет убедиться в использовании правильного уравнения смешивания. Наша картинка все еще будет содержать альфа-значения, поэтому предыдущее уравнение смешивания выдаст неправильные результаты. Альфа источника не должна умножаться на цвет источника. К счастью, все графические API для Android позволяют нам полностью определить, как мы хотим смешивать наши изображения.

В случае с Бобом мы с помощью графического редактора просто устанавливаем альфа-значения всех белых пикселов равными 0, загружаем изображение в формате ARGB8888 или ARGB4444 (возможно, предварительно перемножив) и используем метод прорисовки, производящий альфа-смешивание с правильным уравнением. Результат можно увидеть на рис. 3.27.

Рис. 3.27. Слева Боб уже смешан; справа Боб в редакторе Paint.NET; шахматная доска демонстрирует, что альфа-значение пикселов белого фона равно нулю

ПРИМЕЧАНИЕ

Формат JPEG не поддерживает хранение альфа-значений для пикселов. Используйте для таких случаев формат PNG.

На практике

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

Я предлагаю два простых интерфейса: Graphics и Bitmap. Начнем с интерфейса Graphics (листинг 3.6).     

Листинг 3.6. Интерфейс Graphics

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

Graphics.newPixmapO – загружает изображение в JPEG- или PNG-формате. Мы определяем желаемый формат для результирующего Pixmap, который будет использоваться механизмом загрузки. Мы делаем так, чтобы иметь возможность каким-то образом управлять слепком памяти наших изображений (то есть загружая изображения форматов RGB888 или ARGB8888 в форматы RGB565 или ARGB4444). Имя файла определяет ресурс, содержащийся в АРК-файле нашего приложения.

Graphi cs. clear  – полностью очищает фреймбуфер с соответствующим цветом. Все цвета в нашем маленьком фреймворке определены в виде 32-битных значений формата ARGB8888 (конечно, Pixmaps может иметь и другой формат).

Graphi cs.drawPixel О – устанавливает значение пиксела фреймбуфера с координатами (х; у) в определенный цвет. Координаты за пределами экрана будут игнорироваться (это называется клиппингом).

Graphics. drawLine – аналогичен Graphi cs.drawPixel . Определяем начальную и конечную точку линии, а также ее цвет. Любая часть линии, выходящая за пределы фреймбуфера, будет игнорироваться.

Graphi cs. drawRect  – рисует прямоугольник во фреймбуфере. Координаты (г, у) определяют позицию его левого верхнего угла. Аргументы wi dth и hei ght задают количество пикселов размера прямоугольника. Аргумент color указывает цвет его заполнения.

Graphics .drawPixmap – рисует прямоугольные участки Pi xmap во фреймбуфере. Координаты (х; у) определяют позицию левого верхнего угла расположения цели Pixmap во фреймбуфере. Аргументы srcX и srcY (выраженные в координатной системе Pixmap) обозначают соответствующий левый верхний угол участка прямоугольника, используемого Pixmap. Параметры srcWidth и srcHeight означают размер участка, который мы извлекаем из Pi xmap.

Graphics.getWidth и Graphics .getHeight – возвращают ширину и высоту фреймбуфера в пикселах.

Все методы прорисовки, кроме Graphics .clear, автоматически выполняют смешивание каждого пиксела, с которым они работают (как описано в предыдущем выше). Мы можем отключить смешивание для каждого отдельного пункта для некоторого ускорения прорисовки, однако это усложнит нашу реализацию. Обычно в простых играх вроде Мистера Нома можно оставить смешивание включенным.

Интерфейс Pi xmap описан в листинге 3.7.

Листинг 3.7. Интерфейс Pixmap

Код: скучная рутина

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

Pixmap.getWidth и Pixmap.getHeightO – возвращают ширину и высоту объекта Pixmap в пикселах.

Pixmap. getFormat – возвращает формат Pixel Format, используемый для хранения Pixmap в оперативной памяти.

Pixmap .di spose – экземпляры Pixmap применяют память и потенциально другие системные ресурсы. Если они нам больше не нужны, их следует уничтожить с помощью данного метода.

С этим простым графически модулем мы сможем в дальнейшем реализовать Мистера Нома. Закончим эту главу обсуждением самого игрового фреймворка.

Игровая среда

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

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

Экранами необходимо каким-либо образом управлять (например, нам нужно отслеживать текущий экран и иметь возможность переходить с него на другой, что на самом деле означает уничтожение первого экрана и установку второго экрана в качестве основного).

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

Поскольку все наши игры будут проходить в режиме реального времени (что означает постоянное движение и обновление компонентов), нам необходимо обеспечить как можно более частое обновление состояния текущего экрана. Обычно мы делаем это внутри цикла, называемого основным. Данный цикл прерывается, когда пользователь выходит из игры. Одна итерация цикла называется кадром. Количество кадров в секунду (fps), которое мы можем рассчитать, называется частотой обновления.

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

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

Игровая среда будет иметь дело с настройкой окна и созданием компонента пользовательского интерфейса, который мы будем прорисовывать и от которого будем получать пользовательский ввод.

Давайте сделаем из всего этого немного псевдокода, пока игнорируя такие аспекты управления окном, как постановка на паузу и возврат из нее:

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

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

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

При прерывании работы главного цикла мы можем очистить и освободить все ресурсы, после чего закрыть окно.

Именно таким образом практически каждая игра ведет себя на высоком уровне – обработка ввода, обновление состояния, представление его пользователю и повторение всего этого до бесконечности (или до того момента, когда игроку надоест).

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

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

Игровые и экранные интерфейсы

Вооружившись полученными знаниями, попытаемся разработать интерфейс игры. Он должен реализовывать следующие функции: создание окна и компонента пользовательского интерфейса, а также функций обратного вызова для обработки экранных и пользовательских событий; запуск потока главного цикла программы; отслеживание текущего экрана, обновление и представление его в каждой итерации главного цикла (то есть в кадре); отслеживание любых событий окна (например, постановки на паузу и возобновления игры) из потока пользовательского интерфейса и передача их экрану для соответствующего изменения состояния; предоставление доступа ко всем ранее разработанным модулям: Input, FilelO, Graphics и Audio.

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

Листинг 3.8. Интерфейс Game

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

Для реализации интерфейса Game мы позже будем использовать абстрактный класс AndroidGame, реализующий все методы, кроме Game.getStartScreen – он останется абстрактным. Когда мы создадим экземпляр Androi dGame для нашей игры, то унаследуем и реализуем в нем метод Game. getStartScreen, возвратив экземпляр первого экрана игры.

Чтобы вы получили некоторое впечатление о легкости, с которой будет создаваться наша игра, посмотрите на пример (представьте, что мы уже реализовали класс Androi dGame):

Впечатляет, не правда ли? Все, что нам необходимо сделать, – реализовать экран, с которого должна начинаться наша игра, а дальше Androi dGame, от которого мы наследовались, сделает все остальное. Заглядывая вперед – он же будет заставлять экран MySuperAwesomeStartScreen обновляться и прорисовываться в потоке главного цикла. Обратите внимание – в нашей реализации Screen мы передали конструктору MyAwesomeGame сам экземпляр.

ПРИМЕЧАНИЕ

Если у вас возник вопрос, что же на самом деле создает наш класс MyAwesomeGame, я вам подскажу: AndroidGame будет наследоваться от Activity, автоматически создаваемого операционной системой Android при запуске пользователем игры.

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

Листинг 3.9. Класс Screen

Тут выясняется, что предварительная работа нам пригодится. Конструктор получает экземпляр Game и хранит его в члене с модификатором final, доступном всем подклассам. Благодаря использованию этого механизма мы достигаем двух целей: получаем доступ к низкоуровневым модулям Game для воспроизведения звука, прорисовки экрана, получения реакции пользователя, а также чтения и записи файлов;

 можем устанавливать новый текущий экран, вызывая при необходимости метод Game. setScreen (например, при нажатии кнопки перехода на другой экран).

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

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

Предназначение методов Screen. update и Screen. present  вполне понятно: они обновляют состояние экрана и представляют его пользователю соответственно. Экземпляр Game вызывает их один раз при каждой итерации главного цикла.

Методы Screen.pause и Screen, resume вызываются при постановке игры на паузу и выходе из нее. Эти действия также производятся экземпляром Game и относятся к текущему активному экрану.

Метод Screen. di spose  вызывается экземпляром Game при вызове Game. setScreen. В результате текущий экземпляр Screen освобождает системные ресурсы (например, графические активы, хранящиеся в Pi xmaps), чтобы получить свободное место в памяти для нового экрана. Кроме того, вызов Screen. di spose – последний шанс для экрана убедиться, что вся необходимая информация сохранена.

Простой пример

Продолжим с нашим примером MySuperAwesomeGame – вот весьма простая реализация класса:

Код: скучная рутина

Рассмотрим, что этот класс делает в связке с MySuperAwesomeGame.

1. При создании MySuperAwesomeGame он создает окно, компонент интерфейса (который мы прорисовываем и от которого получаем события пользовательского ввода), функции обратного вызова для обработки событий, а также поток главного цикла. Наконец, этот класс вызывает собственный метод MySuperAwesomeGame. getStartScreen, возвращающий экземпляр класса MySuperAwesomeStartScreen.

2. В конструкторе MySuperAwesomeStartScreen загружаем растровое изображение с диска и храним его в переменной-члене. Таким образом, завершается установка нашего экрана, и управление возвращается классу MySuperAwesomeGame.

3. Поток главного цикла теперь постоянно будет вызывать методы MySuperAwesome StartScreen. updateC) и MySuperAwesomeStartScreen. render только что созданного нами экземпляра.

4. В методе MySuperAwesomeStartScreen.updateC) увеличиваем значение переменной х на каждом новом кадре. Эта переменная хранит координату х изображения, которое мы хотим прорисовывать. Ее значение обнуляется, когда становится равным больше 100.

5. В методе MySuperAwesomeStartScreen. render очищаем фреймбуфер, заполняя его черным цветом (0x00000000 = 0), после чего прорисовываем объект Pi xmap в позиции (хг, 0).

6. Поток главного цикла повторяет шаги 3-5 до тех пор, пока пользователь не выйдет из игры, нажав на устройстве кнопку Назад. В этом случае экземпляр Game вызовет метод MySuperAwesomeStartScreen. di spose, очищающий Pixmap.

И вот она, наша первая игра. Все, что пользователь увидит на ее экране, – картинку, движущуюся слева направо. Не слишком впечатляющий геймплей, но мы продолжим над ним работать. Заметьте, что в Android игра может быть приостановлена и возобновлена в любой момент. Наша реализация MyAwesomeGame вызывает в этих случаях методы MySuperAwesomeStartScreen. pause и MySuperAwesomeStartScreen. resume, что приводит к приостановке выполнения главного цикла программы на время паузы.

Последняя проблема, которую нам сейчас необходимо обсудить, – кадронезависимое движение.

Кадронезависимое движение

Представим, что пользователь запустил нашу игру на устройстве, поддерживающем частоту обновления 60 кадров в секунду. Поскольку мы увеличиваем значение MySuperAwesomeStartScreen. х на 1 в каждом кадре, наш Pixmap переместится на 100 пикселов за 100 кадров. При частоте обновления 60 кадров в секунду (fps) достижение положения (100; 0) займет примерно 1,66 секунды.

Теперь допустим, что другой пользователь играет в нашу игру на другом устройстве, обеспечивающем частоту обновления 30 кадров в секунду. В данном случае наш Pixmap будет перемещаться в секунду на 30 пикселов, поэтому точка с координатами (100,0) будет достигнута через 3,33 секунды.

Это плохо. В нашей простой игре это может не иметь значения, но представьте на месте Pi xmap Супер Марио и подумайте, что может для него значить такая зависимость от аппаратных возможностей. Например, мы нажимаем стрелку Вправо, и Марио бежит в ту же сторону. В каждом кадре он продвигается на 1 пиксел (как и Pi xmap). На устройстве с частотой кадров 60 кадров в секунду Марио будет бежать вдвое быстрее, чем на телефоне с частотой 30 кадров в секунду. Таким образом, характеристики аппарата могут полностью изменять показатели производительности игры. Нам необходимо это исправить.

Решение этой проблемы – кадронезависимое движение. Вместо перемещения нашего объекта Pi xmap (или Марио) на фиксированную величину за один кадр, мы определим скорость его движения в юнитах за секунду. Допустим, мы хотим, чтобы наш Pi xmap перемещался на 50 пикселов в секунду. Помимо этого значения нам также нужна информация о времени, прошедшем с последнего перемещения Pixmap. И это как раз тот момент, когда вступает в игру тот странный дельта-интервал. Он показывает нам, сколько времени прошло с последнего обновления. Таким образом, наш метод MySuperAwesomeStartScreen.updateO должен выглядеть примерно так:

Теперь, если наша игра проходит при частоте 60 кадров в секунду, передаваемый методу дельта-интервал всегда будет равен примерно 0,016 секунды (1/60). Таким образом, в каждом кадре будет продвижение на 0,83 (50  0,016) пиксела, а за секунду – около 100 пикселов (60 х 0,85). Проверим результаты при частоте 30 кадров в секунду: 1,66 пиксела (50 х 1 / 30). Умножая на 30, снова получаем 100 пикселов в секунду. Итак – неважно, на каком устройстве запускается наша игра, вся анимация и движение в ней будут всегда соответствовать текущему времени.

Однако если вы попробовали проверить эти расчеты на предшествующем коде, то увидели, что наш Pixmap на самом деле вообще не двигается при частоте 60 кадров в секунду. Так происходит из-за ошибки в нашем коде. Попробуйте угадать, где именно. Это довольно малозаметная, но распространенная ловушка, часто подстерегающая разработчиков игр. Переменная х, которую мы увеличиваем в каждом кадре, определена как 1 nteger. Прибавление 0,83 к типу integer не дает никакого эффекта. Для исправления этой неприятности нужно просто заменить тип данных переменной х на float. Кроме того, необходимо добавить сумму при вызове Graphi cs. drawPi xmapO.

ПРИМЕЧАНИЕ

Хотя расчеты значений с плавающей точкой в Android обычно осуществляются медленнее, чем для целочисленных значений, эта разница обычно ничтожна, поэтому мы можем не обращать на нее внимания.

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

Подводя итог

Изучив к этому моменту столько насыщенных и информативных страниц и, вы должны получить неплохое представление о том, из каких этапов состоит процесс создания игры. Мы изучили несколько самых популярных на Android Market -жанров и сделали некоторые выводы. Мы решили нарисовать игру с нуля, используя только ножницы, ручку и несколько листов бумаги. Наконец, мы исследовали теоретические основы процесса разработки игр и даже создали набор интерфейсов и абстрактных классов, которые будем использовать в е далее для реализации наших идей, основанных на теоретических идеях. Если вы думаете, что вам необходимо продвинуться дальше пределов, обратитесь за помощью к Сети – все ключевые слова вы знаете. Понимание принципов – ключ к разработке стабильных и высокопроизводительных приложений. Теперь давайте реализовывать нашу игровую среду для Android.

Источник: Mario Zechner / Марио Цехнер, «Программирование игр под Android», пер. Егор Сидорович, Евгений Зазноба, Издательство «Питер»

По теме:

  • Комментарии