Добавление новых языков в GraalVM осуществляется с помощью специального фреймворка Truffle, выполненного в виде библиотеки Java. Фреймворк предназначен для создания реализаций языков программирования в качестве интерпретаторов для самомодифицируемых абстрактных синтаксических деревьев (AST). При желании на его основе можно создать собственный язык, в официальных репозиториях GraalVM подробно рассмотрен пример реализации такого проекта под названием SimpleLanguage. Интерпретаторы, которые были написаны с использованием фреймворка Truffle, будут автоматически использовать GraalVM как JIT-компилятор непосредственно для самой реализации языка запускаемой на JVM-платформе и, соответственно, иметь возможность взаимодействия и двустороннего обмена данными в одном и том же пространстве памяти посредством специально разработанного протокола и программного интерфейса Polyglot API.
Платформа GraalVM вместе с исполняемой программой на смеси самых разных языков может быть представлена в виде автономного и самодостаточного исполняемого файла, либо работать поверх OpenJDK, Node.js или даже внутри Oracle Database.
На текущий момент сразу «из коробки» поддерживаются следующие языки программирования и технологии:
Помимо этого, с помощью встроенного пакетного менеджера в дистрибутив GraalVM можно добавить поддержку:
Стоит заметить, что реализации языков и технологий из списка выше пока являются экспериментальными и в настоящее время не рекомендуются для производственного использования. Но для моей задачи это не столь принципиально.
Кроме того, посредством ahead-of-time (AOT) компиляции имеется возможность создавать автономные исполняемые файлы, называемые нативными образами, которые используют не классическую Java VM, а более специализированную Substrate VM, реализующую компактную среду выполнения. В итоге программы запускаются значительно быстрее и расходуют гораздо меньше оперативной памяти. Но при этом теряются некоторые преимущества just-in-time (JIT) компиляции, доступной на классических платформах. Для формирования подобных нативных образов в большинстве случаев требуются значительные ресурсы CPU и десятки гигабайт оперативной памяти, поэтому их создание лучше всего производить на каких-нибудь мощных сборочных серверах или рабочих станциях.
Корпорация Oracle в настоящее время позиционирует GraalVM как единую и идеальную платформу для создания различных микросервисов. При этом она уже имеет влияние на развитие классического OpenJDK. Например, встраиваемый движок JavaScript под названием Nashorn уже удалён из JDK 15, а в качестве его замены предлагается попробовать именно GraalVM. Неизвестно, как дальше будут развиваться события и будет ли GraalVM в будущем предлагаться в качестве рекомендуемой JVM-платформы по умолчанию, но судя по весьма активному развитию и маркетинговому продвижению в последнее время, Oracle настроен вполне серьёзно. Так что время покажет.
Для конечного использования предлагаются две редакции: бесплатная GraalVM Community и платная GraalVM Enterprise, отличия между которыми описаны на этой страничке официального сайта GraalVM. В основном они сводятся к обеспечению лучшей производительности, меньшего потребления оперативной памяти и официальной поддержке от специалистов корпорации Oracle в платной версии. В этой статье я буду ориентироваться только на возможности GraalVM Community, распространяемой свободно.
Теперь, когда у вас имеется общее представление о GraalVM, перейдём к более простым сущностям и обозначим практическую задачу, которая бы неплохо ложилась на плечи возможностей этой платформы.
1. Подсветка синтаксиса фрагментов кода на стороне сервера
2. Создание простейшего прототипа
3. Установка GraalVM и сопутствующих библиотек, запуск прототипа
4. От прототипа к готовому сервису
5. Подведём итоги
Когда-то давно мне стало жутко интересно, какими технологиями крупные хостеры IT-проектов вроде GitHub, Gitorious и Bitbucket подсвечивают наш исходный код на своих серверах. Проведя некоторое исследование, я пришёл к следующим результатам:
Таким образом, я пришёл к выводу, что все крупные компании использовали и активно развивали библиотеку Pygments, благодаря чему этот проект сегодня поддерживает наибольшее количество языков программирования, которые он может подсвечивать.
Шло время и недавно я решил снова перепроверить чем же сейчас пользуются крупные хостеры исходного кода. За это время Gitorious был куплен GitLab‘ом, который в итоге стал новым крупным и популярным сервисом. На сей раз получились такие результаты:
Видимо аудитория этих сервисов росла и использовать обёртки, запускающие Python из-под Ruby, стало несколько накладно и дорого. Если посмотреть на официальный сайт библиотеки Rouge, то одним из преимуществ там явно обозначают то, что теперь не требуется спавнить процессы интерпретатора Python, так как Rouge уже изначально написан на Ruby.
Особняком ещё стоит библиотека Highlight.js на языке программирования JavaScript, получившая огромную популярность и широкое распространение на самых разнообразных сайтах и сервисах. Её применяют в основном чтобы подсвечивать код на стороне клиента, но никто не запрещает использовать эту библиотеку и для подсветки на стороне сервера.
Если бы вы писали сайт на каком-либо языке JVM-стека вкупе с каким-нибудь популярным веб-фреймворком и перед вами бы стояла задача реализовать серверную подсветку синтаксиса различных фрагментов кода, то у вас бы испортилось настроение. К большому сожалению, JVM-платформа не обзавелась такими библиотеками, как Pygments, Rouge и Highlight.js, которые поддерживают сотни языков программирования. Все известные мне попытки портирования Pygments на Java на сегодня по сути заброшены и поэтому вам для выполнения этой задачи пришлось бы делать такие же обёртки над чужеродными библиотеками, которые были описаны выше.
Альтернатива видится в использовании Jython, JRuby или Nashorn, то бишь внешних реализаций Python, Ruby и JavaScript для платформы JVM. Но с ними не всё так гладко, как хотелось бы. Во-первых, размер вашего JAR-файла или WAR-файла и время его запуска существенно увеличится. Во-вторых, некоторые библиотеки предоставляют реализации версий языков далеко не первой свежести, например, Jython так и остался на Python 2, который уже устарел и новые версии Pygments на нём просто не работают. В-третьих, установка сторонних библиотек внутрь конечного файла для развёртывания в некоторых случаях далеко не так тривиальна и сопровождается грудой различных проблем и точек отказа.
В итоге все эти недостатки усложняют решение довольно тривиальной задачи. И именно в этот момент на сцену выходит GraalVM, который позволяет достаточно просто совмещать и склеивать между собой код и компоненты на различных языках программирования. Платформу JVM со своей кучей библиотек мы можем обогатить батарейками из экосистем других языков и использовать эксклюзивные библиотеки, альтернативы которых недоступны на нашей платформе.
Поэтому у меня появилась идея создать демонстрационный сервис с использованием GraalVM, который бы объединял в себе возможность работы различных библиотек для подсветки синтаксиса на стороне сервера. Он бы неплохо продемонстрировал готовность и удобство сопряжения нескольких языков программирования в рамках платформы GraalVM.
После обозначения практической задачи я приступил к созданию простейшего прототипа, который бы демонстрировал нужные мне возможности и подсвечивал скармливаемые ему фрагменты исходного кода. Я выбрал три библиотеки для использования в своём проекте:
Их связующим звеном я решил выбрать Java 8, хотя с таким же успехом этот выбор мог быть сделан в сторону Kotlin, Scala или Java 11.
Итак, покопавшись по официальным документациям и гайдам этих библиотек и выяснив как именно подсвечивать с их помощью код, я написал такую вот простенькую консольную программку примерно из ста строк:
// Highlighter.java, no comments, no checks. // $ javac Highlighter.java // $ jar -cvfe highlighter.jar Highlighter *.class // $ cat hello.py | java -jar highlighter.jar rouge python import org.graalvm.polyglot.Context; import java.io.File; import java.io.FileNotFoundException; import java.util.Scanner; public class Highlighter { private abstract class Highlight { protected final Context polyglot = Context.newBuilder("js", "python", "ruby").allowAllAccess(true).allowIO(true) .build(); protected abstract String language(); protected abstract String renderHtml(String language, String rawCode); protected String execute(String sourceCode) { try { return polyglot.eval(language(), sourceCode).asString(); } catch (RuntimeException re) { re.printStackTrace(); } return sourceCode; } protected void importValue(String name, String value) { try { polyglot.getBindings(language()).putMember(name, value); } catch (RuntimeException re) { re.printStackTrace(); } } } private class Hjs extends Highlight { @Override protected String language() { return "js"; } @Override public String renderHtml(String language, String rawCode) { importValue("source", rawCode); String hjs = ""; try { hjs = new Scanner(new File("highlight.min.js")).useDelimiter("\\A").next(); } catch (FileNotFoundException fnfe) { fnfe.printStackTrace(); } final String renderLanguageSnippet = hjs + "\n" + "hljs.highlight('" + language + "', String(source)).value"; return execute(renderLanguageSnippet); } } private class Rouge extends Highlight { @Override protected String language() { return "ruby"; } @Override public String renderHtml(String language, String rawCode) { importValue("$source", rawCode); final String renderLanguageSnippet = "require 'rouge'" + "\n" + "formatter = Rouge::Formatters::HTML.new" + "\n" + "lexer = Rouge::Lexer::find('" + language + "')" + "\n" + "formatter.format(lexer.lex($source.to_str))"; return execute(renderLanguageSnippet); } } private class Pygments extends Highlight { @Override protected String language() { return "python"; } @Override public String renderHtml(String language, String rawCode) { importValue("source", rawCode); final String renderLanguageSnippet = "import site" + "\n" + "from pygments import highlight" + "\n" + "from pygments.lexers import get_lexer_by_name" + "\n" + "from pygments.formatters import HtmlFormatter" + "\n" + "formatter = HtmlFormatter(nowrap=True)" + "\n" + "lexer = get_lexer_by_name('" + language + "')" + "\n" + "highlight(source, lexer, formatter)"; return execute(renderLanguageSnippet); } } public String highlight(String library, String language, String code) { switch (library) { default: case "hjs": return new Hjs().renderHtml(language, code); case "rouge": return new Rouge().renderHtml(language, code); case "pygments": return new Pygments().renderHtml(language, code); } } public static void main(String[] args) { Scanner scanner = new Scanner(System.in).useDelimiter("\\A"); if (scanner.hasNext()) { String code = scanner.next(); if (!code.isEmpty()) { System.out.println(new Highlighter().highlight(args[0], args[1], code)); } } } }
Как видно, в этом фрагменте смешаны четыре разных языка программирования. Утилита принимает на вход stdin в виде текста исходного файла, передаваемые аргументы определяют используемую библиотеку для подсветки и язык фрагмента, затем подсвечивается код и выводится готовый HTML на stdout терминала. Звучит просто и понятно, но для компиляции и запуска этой программы требуется установить GraalVM и сопутствующие пакеты нужных библиотек.
Для экспериментов я выбрал сервер с ванильным CentOS 7, на который без каких либо проблем разворачивается дистрибутив GraalVM.
Рецепт установки платформы у меня получился следующим:
curl -LOJ https://github.com/graalvm/graalvm-ce-builds/releases/download/vm-20.3.0/graalvm-ce-java8-linux-amd64-20.3.0.tar.gz # curl -LOJ https://github.com/graalvm/graalvm-ce-builds/releases/download/vm-20.3.0/graalvm-ce-java11-linux-amd64-20.3.0.tar.gz cd /opt/ sudo mkdir graalvm sudo chown `whoami`:`whoami` graalvm cd /opt/graalvm/ tar -xvzf ~/graalvm-ce-java8-linux-amd64-20.3.0.tar.gz rm ~/graalvm-ce-java8-linux-amd64-20.3.0.tar.gz
Стоит заметить, что в настоящий момент времени GraalVM 20.3.0 поддерживает только LTS-версии Java 8 и Java 11, поэтому при создании проектов на этой платформе следует помнить об этом и пока забыть про вкусности новых версий Java и JVM-платформ. Итак, после выполнения этих команд в директории /opt/ у вас будет развёрнут GraalVM, но в него ещё нужно добавить поддержку дополнительных компонентов.
Рецепт установки языков программирования в GraalVM и требуемых библиотек подсветки кода таков:
export GRAALVM_HOME=/opt/graalvm/graalvm-ce-java8-20.3.0 export JAVA_HOME=$GRAALVM_HOME export PATH=$GRAALVM_HOME/bin:$PATH gu install python gu install ruby # /opt/graalvm/graalvm-ce-java8-20.3.0/jre/languages/ruby/lib/truffle/post_install_hook.sh graalpython -m ginstall install setuptools curl -LOJ https://github.com/pygments/pygments/archive/2.7.3.tar.gz tar -xvzf pygments-2.7.3.tar.gz cd pygments-2.7.3/ graalpython setup.py install --user cd .. rm -Rf pygments-2.7.3/ pygments-2.7.3.tar.gz gem install rouge curl -LOJ https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.4.1/highlight.min.js
Перед использованием инструментов из дистрибутива GraalVM следует подгрузить нужные нам переменные окружения. При желании их подгрузку можно сделать при старте сервера для определённого пользователя или для всей системы сразу. Утилита gu является неким пакетным менеджером и предназначена для установки, удаления и обновления компонентов GraalVM. Нативные образы я не стал пересобирать, поскольку это достаточно долгий и ресурсоёмкий процесс. Он потребуется только в том случае, если вы захотите сформировать автономный исполняемый файл вашей программы. Пост-установочный скрипт после процесса инсталляции поддержки языка Ruby я тоже не стал выполнять, поскольку библиотека Rouge никак не взаимодействует с сетью и интернетом. Для использования Highlight.js достаточно просто скачать минифицированный файл библиотеки, платформа Node.js здесь никак не задействуется.
Ситуацию с Pygments следует разобрать немного подробнее. В случае с Python, его менеджер пакетов pip доступен лишь в виртуальном окружении virtualenv и для упрощения я решил отказаться от изоляции и использовать способ установки библиотеки Pygments посредством инструмента setuptools и загрузки исходников. Стоит заметить, что постоянные холодные вызовы интерпретатора graalpython довольно ресурсоёмки и на выполнение этих команд потребуется некоторое время, однако после завершения процесса требуемая библиотека будет сразу нам доступна. Кстати, установку и удаление библиотек можно производить и обычным системным pip, который входит в поставку дистрибутива операционной системы. В моём случае использовался CentOS 7, где по умолчанию установлен лишь Python 2, а усложнять инструкцию установкой современного Python 3 мне не хотелось, тем более раз для GraalVM доступна его собственная реализация Python.
Рецепт сборки и запуска консольной программки-прототипа из предыдущего раздела:
export GRAALVM_HOME=/opt/graalvm/graalvm-ce-java8-20.3.0
export JAVA_HOME=$GRAALVM_HOME
export PATH=$GRAALVM_HOME/bin:$PATH
javac Highlighter.java
jar -cvfe highlighter.jar Highlighter *.class
cat hello.py
#!/usr/bin/env python
print("Hello, World!")
cat hello.py | java -jar highlighter.jar hjs python
#!/usr/bin/env python
print("Hello, World!")
cat hello.py | java -jar highlighter.jar rouge python
#!/usr/bin/env python
print("Hello, World!")
cat hello.py | java -jar highlighter.jar pygments python
#!/usr/bin/env python
print("Hello, World!")
Как видно, всё прекрасно работает! Только не стоит забывать что для Highlight.js требуется положить файлик highlight.min.js рядом с нашей программкой. Использование внутренних ресурсов JAR-файла усложнило бы этот пример, поэтому я решил выбрать именно такой простой способ.
При желании все эти рецепты можно аккуратно завернуть в контейнеры вроде Docker или Podman, кому что нравится. Официальные образы от Oracle с GraalVM вполне себе доступны на том же Docker Hub.
Имея на руках готовый прототип я решил превратить его в простенький демонстрационный сервис, представляющий собой веб-сайт с возможностью сохранения фрагментов исходного кода и выбора библиотеки для запекания подсветки синтаксиса в HTML.
Для реализации этой задачи я взял известный в Java-экосистеме фреймворк Spring, который в настоящее время без особых нареканий работает на GraalVM и даже обозначен на официальном сайте платформы. В качестве проксирующего веб-сервера был выбран Nginx, а для базы данных я использовал PostgreSQL. Для front-end‘а мной был выбран несколько устаревший в современном мире, но всё ещё использующийся подход с рендерингом HTML на стороне сервера при помощи шаблонизатора Thymeleaf. В общем, я остановился на одном из самых популярных фреймворков и его сопутствующих инструментах вроде Spring Boot 2.4.1 или Spring Data JPA, которые сильно ускоряют разработку и сокращают рутинный код. Но при этом с GraalVM вы вольны выбирать другие и более легковесные Java-фреймворки, которые больше ориентированы на создание микросервисов. Официальный сайт рекомендует посмотреть на Micronaut и Quarkus, преимущество которых состоит в возможности более лёгкого создания нативных образов из-за меньшего потребления оперативной памяти при их формировании.
Отправку фрагментов кода на веб-сайт я стилизовал под популярный ныне на многих IT-ресурсах язык разметки Markdown:
```python #!/usr/bin/env python print("Hello, World!") ```
В той строке, где вы определяете язык программирования, можно разместить дополнительные опции, например, номер или диапазон строк кода, на которых нужно сфокусировать особое внимание или параметр, отключающий колонку в виде линейки с номерами строк.
Тёмная тема в хакерском стиле создавалась под впечатлением от интерфейса старых мобильных телефонов Motorola на P2K, вроде E398, которые оставили в моей юности приятные и тёплые воспоминания. Оболочка называлась «Techno» и выглядела следующим образом:
Надеюсь, вы не станете меня проклинать за моё извращённое чувство прекрасного и безумные умения в вёрстке, ибо я не имею должного опыта веб-разработки. Но если всё-таки станете, то на сайте доступна более нейтральная светлая тема, простой дизайн которой я позаимствовал с похожего сервиса по обмену фрагментами кода: paste.org.ru:
Итак, прототип потихоньку начал обрастать различной функциональностью для выхода в мир, которую описывать здесь особого смысла я не вижу, всё же эта статья не о том, как быстро сделать подобный веб-сайт с помощью фреймворка Spring Boot. Расскажу лучше о тех проблемах, с которыми мне пришлось столкнуться и которые я попытался преодолеть.
Фрагменты кода на веб-сайте я решил отображать в таблицах, как это делает GitHub, но оказалось что с этим не всё так гладко. Библиотека Highlight.js, например, позиционируется разработчиком как максимально простая и поэтому принципиально не умеет оборачивать фрагменты кода в табличные строки. С другой стороны, генерируемые таблицы Pygments и Rouge слабо совместимы между собой. Поэтому мне пришлось выключать табличное отображение вообще, просто подсвечивать фрагмент кода выбранной библиотекой и построчно оборачивать его в таблицу уже на стороне Java.
Подобный подход привёл ещё к одной проблеме, которую проще всего продемонстрировать на следующем примере:
/* * Многострочный комментарий! */ /* * Многострочный комментарий! */1 /* 2 * Многострочный комментарий! 3 */
Библиотеки Highlight.js и Rouge не оборачивали каждую линию кода в автономные HTML-теги, поэтому при генерации таблицы они разрывались и подсветка кода работала некорректно. Я постарался исправить эту проблему с помощью обычного стека из стандартной библиотеки структур данных в Java. Упрощённый алгоритм выглядит примерно следующим образом: проходим по строке, когда детектим открывающий тег подсветки, то кладём его на стек, а когда детектим закрывающий, то просто убираем последний элемент со стека. Если к концу строки стек не оказывается пустым, то закрываем все открытые теги в текущей строке, а в начале следующей строки открываем те теги, которые остались у нас на стеке. Решение получилось немного топорным, но вполне себе рабочим.
1 /* 2 * Многострочный комментарий! 3 */
В итоге после обработки подобным алгоритмом пример выше приводится к удобоваримому виду, подсветка начинает работать, а сгенерированный HTML становится корректным. К слову, некоторые похожие сервисы по обмену фрагментов кода имеют схожую проблему, которая так и не решена, поэтому я отписался их владельцам и описал им этот метод исправления.
С последним препятствием я столкнулся уже на слабенькой VPS от Oracle. Оказалось, что экспериментальная поддержка языка программирования Python в GraalVM вкупе с библиотекой Pygments не влазят в мои скромные 1 GB оперативной памяти на виртуальном сервере. Да и сама реализация Python показалась мне ещё несколько сыроватой и медленной, например, иногда в процессе подсветки кода кое-где теряются пробелы и переводы строк, хотя в классическом системном Python всё работает отлично. Поэтому мне пришлось поменять сервер на другой, с более мощным железом и 4 GB RAM на борту.
В целом, я получил положительный опыт работы с GraalVM и его инструментарием, достаточно быстро смог решить поставленную перед собой задачу сопряжения полезных батареек из экосистем разных языков программирования в рамках одной общей платформы. Из недостатков могу лишь отметить несколько сыроватую и медленную реализацию Python, наверное как раз по этой причине его поддержка до сих пор является в большей степени экспериментальной. А вот с реализацией Ruby, которая тоже является экспериментальной, и с поддержкой JavaScript, который сразу входит в стандартную поставку GraalVM, я не заметил каких-либо проблем, да и работают они вполне себе быстро.
Все свои наработки, рецепты развёртывания и сборки, весь исходный код я разместил в репозитории на GitHub:
https://github.com/EXL/CodePolyglot
Сам демонстрационный сервис можно потыкать палочкой по этой ссылке:
Примечание: из-за наплыва посетителей после публикации статьи может случиться так, что сервер станет недоступным, всё-таки там совсем слабенький канал. Так что прошу меня заранее простить за технические неполадки. Если вам интересен этот проект, то на своих рабочих станциях с кучей оперативной памяти вы можете легко его развернуть и поиграться.
Отдельно стоит поговорить про несколько неудачный опыт создания нативных образов. В тех случаях, когда используются и смешиваются несколько языков программирования, их формирование превращается в пытку. Забегая вперёд сообщу, что для маленького прототипа, который был описан в статье выше, в его нативном образе удалось заставить работать лишь библиотеку Highlight.js вкупе с JavaScript. При попытке добавления других языков программирования автономный исполняемый файл раздувался совсем уж до неприличных размеров в 500 MB и отказывался видеть не только сторонние батарейки, но и стандартные библиотеки Python и Ruby. Копание в переменных окружения и флажках сборки исправило пару проблем, но удобоваримого результата у меня так и не получилось. К тому же, нельзя не упомянуть продолжительное время AOT-компиляции (порядка 10-15 минут), значительное потребление RAM (порядка 20 GB) и ресурсов CPU в процессе сборки.
Если с такими проблемами создания нативного образа я столкнулся на простейшем прототипе, то что уж говорить о моих тщетных попытках преобразования всего сервиса в автономный исполняемый файл. В экосистеме Spring совсем недавно появился экспериментальный проект Spring Native 0.8.5, позволяющий формировать нативные образы сервисов использующих библиотеки Spring. Оригинальные версии Java-библиотек, которые не работают должным образом под Substrate VM, вырезаются или подменяются на патченные, например, в их число входит встраиваемый веб-сервер Tomcat. При этом при сборке нужно ещё сделать некоторые ухищрения и телодвижения вроде правильной настройки Hibernate, подгрузки конфигурационного JSON-файла с описанием некоторой рефлексии и расстановки обязательных флажков для утилиты native-image, которая формирует нативные образы. В качестве системы сборки в Spring Native используется Maven, так как поддержка Gradle находится ещё на начальном этапе развития. В общем, всё выглядит слишком экспериментальным и сырым. В итоге мне удалось добиться корректной сборки нативного образа, но без поддержки каких-либо сторонних языков программирования кроме тех, что доступны на JVM-платформе. При попытках их добавления процесс зависал и ничего полезного так и не происходило в течении 30 минут, после чего я просто останавливал задачу. Далее, при запуске исполняемого файла сыпались ошибки встраиваемого языка SpEL в шаблонах Thymeleaf и я пока отступил от решения этой проблемы.
Для себя я сделал вывод, что AOT-компиляция может быть интересной в том случае, если в проекте не задействован Polyglot API, не смешано множество языков программирования и сервис используется как back-end к чему либо. Вместо Spring для AOT пока лучше использовать более легковесный Quarkus, который как раз опирается на возможность формирования нативных образов в GraalVM. А для проектов, которые используют множество языков и серверные HTML-шаблонизаторы, неплохо работает традиционный подход с JIT-компиляцией и запуском на JVM.
Версии реализаций языков программирования и технологий доступных на платформе GraalVM 20.3.0:
Некоторые полезные ссылки:
Надеюсь, мой опыт и эта статья будут полезны тем, кто когда-нибудь заинтересуется платформой GraalVM и её возможностями по использованию множества языков программирования в одном окружении.
P.S. Искренне поздравляю посетителей моего блога с наступающим 2021 годом, желаю вам ребята исполнения всех ваших желаний и крепкого сибирского здоровья!
P.P.S. Благодарю Zorge.R за поддержку! Без него эта статья никогда бы не появилась на свет.
Update 07-Jan-2021: В некоторых комментариях сотрудников GitHub нашлась интересная информация. Та проприетарная библиотека, которая вот уже шесть лет используется ими для подсветки синтаксиса, называется PrettyLights и её исходный код до сих пор не может быть открыт из-за проблем с лицензированием бюрократического толка, хотя работы в этом направлении ведутся. Возможно библиотека написана на каком-нибудь компилируемом языке программирования, вроде «C», дабы обеспечить высокую скорость обработки больших объёмов кода от огромного количества пользователей сервиса. Кроме того, имеется интересная информация о тщательном аудите библиотеки Pygments, которую они использовали ранее. Аудит не выявил никаких проблем с безопасностью. Подробнее можно почитать по этим ссылкам: [1], [2], [3].
]]>Удивительно, как трепетно и качественно его перенесли, сохранив не только абсолютно всю 32-летнюю историю проекта, но и багрепорты (попали в Issues), патчи (попали в PRs), релизы и ветки. Надпись «32 years ago» рядом с файлами вызывает невольную улыбку.
Что ещё делать в этот унылейший пятничный вечер, когда на улице неприятно моросит дождь со снегом, а все уличные дорожки погрязли в осенней слякоти? Правильно, красноглазить! Так что я ради эксперимента и интереса решил взять и собрать древний Perl на современной x86_64-машинке с последней версией GCC 9.2.0 в качестве компилятора. Сможет ли такой старый код пройти проверку временем?
Чтобы было совсем уж аутентичненько и некромантненько, я развернул виртуальную машину с голыми иксами и оконным менеджером twm, который тоже родом из 1987 года. Кто знает, может быть Larry Wall писал свой Perl используя именно twm, так сказать bleeding edge technology того времени. Используемый дистрибутив — Arch Linux. Просто потому что в его репозитории есть некоторые полезные вещи, которые впоследствии мне пригодились. Итак, поехали!
1. Подготовка окружения
2. Конфигурирование исходного кода
3. Ошибки файла грамматики yacc
4. Ошибки компиляции кода на «C»
5. Исправление некоторых ошибок Segmentation fault
6. Подведение итогов и полезные ссылки
Сперва устанавливаем на развёрнутую операционную систему в виртуальной машине весь необходимый для сборки и редактирования исходного кода джентльменский набор утилит и компиляторов: gcc, make, vim, git, gdb и т. д. Некоторые из них уже установлены, а другие доступны в мета-пакете base-devel, его нужно обязательно установить, если он не установлен. После того, как окружение готово к активным действиям, получаем копию исходного кода Perl 32-летней выдержки!
$ git clone https://github.com/Perl/perl5/ --depth=1 -b perl-1.0
Благодаря особенностям Git’а нам не требуется тянуть кучу файлов, чтобы добраться до самого первого релиза проекта:
* commit 8d063cd8450e59ea1c611a2f4f5a21059a2804f1 (grafted, HEAD, tag: perl-1.0) Commit: Larry Wall <lwall@jpl-devvax.jpl.nasa.gov> CommitDate: Fri Dec 18 00:00:00 1987 +0000 a "replacement" for awk and sed
Мы скачиваем лишь небольшой объём данных и в итоге репозиторий с исходным кодом первой версии Perl занимает всего 150 КБ.
В то тёмное и дремучее время не было такой элементарной вещи, как autotools (счастье-то какое!), однако в корне репозитория имеется скрипт Configure. В чём же дело? А дело в том, что Larry Wall и является изобретателем подобных скриптов, которые позволяли сгенерировать Makefile’ы под самые разношёрстные UNIX-машины того времени. Как гласит одноимённая статья про эти скрипты в английской Википедии, Larry Wall ещё три года до написания Perl’а поставлял файл Configure с некоторым своим софтом, например, программой для чтения новостей rn. Впоследствии Perl не стал исключением и для его сборки использовался уже обкатанный на многих машинах скрипт. Позже эту идею подхватили и другие разработчики, например, программисты из компании Trolltech. Они использовали для конфигурации сборки своего фреймворка Qt похожий скрипт, который многие путают с configure из autotools. Именно зоопарк таких вот скриптов от разных разработчиков и послужил толчком к созданию средства для их упрощённой и автоматической генерации.
Скрипт Configure «старой закалки», что уже видно по его Shebang‘у, в котором имеется пробел:
$ cat Configure | head -5 #! /bin/sh # # If these # comments don't work, trim them. Don't worry about any other # shell scripts, Configure will trim # comments from them for you. #
Согласно комментарию, оказывается существовали shell’ы, в скриптах которых не было возможности оставлять комментарии! Ситуация с пробелом выглядит непривычно, но когда-то подобное было нормой, см. дополнительную информацию по ссылкам здесь. Самое главное, что для современных shell-интерпретаторов нет никакой разницы, имеется там пробел или нет.
Хватит лирики, переходим к делу! Запускаем скрипт и видим интересное предположение, которое оказывается не совсем верным:
$ ./Configure (I see you are using the Korn shell. Some ksh's blow up on Configure, especially on exotic machines. If yours does, try the Bourne shell instead.) Beginning of configuration questions for perl kit. Checking echo to see how to suppress newlines... ...using -n. Type carriage return to continue. Your cursor should be here-->
Удивительно, что скрипт является интерактивным и содержит огромную кучу различной справочной информации. Модель взаимодействия с пользователем построена на диалогах, анализируя ответы на которые скрипт меняет свои параметры, по которым он впоследствии будет генерировать Makefile’ы. Меня лично заинтересовала проверка того, все ли команды оболочки находятся на своём месте?
Locating common programs... expr is in /bin/expr. sed is in /bin/sed. echo is in /bin/echo. cat is in /bin/cat. rm is in /bin/rm. mv is in /bin/mv. cp is in /bin/cp. tr is in /bin/tr. mkdir is in /bin/mkdir. sort is in /bin/sort. uniq is in /bin/uniq. grep is in /bin/grep. Don't worry if any of the following aren't found... test is in /bin/test. egrep is in /bin/egrep. I don't see Mcc out there, offhand.
Видимо раньше это было далеко не так. Интересно, а за что отвечает утилита Mcc, которую не удалось найти? Самое забавное, что этот скрипт в лучших хакерских традициях того времени полон дружелюбного юмора. Сейчас подобное практически не увидишь:
Is your "test" built into sh? [n] (OK to guess) OK Checking compatibility between /bin/echo and builtin echo (if any)... They are compatible. In fact, they may be identical. Your C library is in /lib/libc.a. You're normal. Extracting names from /lib/libc.a for later perusal...done Hmm... Looks kind of like a USG system, but we'll see... Congratulations. You aren't running Eunice. It's not Xenix... Nor is it Venix... Checking your sh to see if it knows about # comments... Your sh handles # comments correctly. Okay, let's see if #! works on this system... It does. Checking out how to guarantee sh startup... Let's see if '#!/bin/sh' works... Yup, it does.
На большинство вопросов я ответил значением по умолчанию, либо тем, что мне предложил скрипт. Особенно порадовал и удивил запрос флажков для компилятора и линковщика:
Any additional cc flags? [none] Any additional ld flags? [none]
Туда можно прописать что-нибудь интересное, например,
-m32для сборки 32-битного исполняемого файла или библиотеку, которая требуется при линковке. На последний вопрос скрипта:
Now you need to generate make dependencies by running "make depend". You might prefer to run it in background: "make depend > makedepend.out &" It can take a while, so you might not want to run it right now. Run make depend now? [n] y
Я ответил положительно. Древняя утилита makedepend, судя по её страничке в Википедии, была создана в самом начале жизни проекта Athena, для облегчения работы с Makefile’ами. Этот проект подарил нам X Window System, Kerberos, Zephyr и повлиял на множество других привычных сегодня вещей. Всё это замечательно, но вот только откуда эта утилита в современном Linux-окружении? Она давно уже никем и нигде не используется. Но если посмотреть внимательно в корень репозитория, оказывается, что Larry Wall написал её скриптовую версию-заменитель, которую нам заботливо распаковал и выполнил конфигурационный скрипт.
Выполнение makedepend завершилось с некоторыми странными ошибками:
./makedepend: command substitution: line 82: unexpected EOF while looking for matching `'' ./makedepend: command substitution: line 83: syntax error: unexpected end of file ./makedepend: command substitution: line 82: unexpected EOF while looking for matching `'' ./makedepend: command substitution: line 83: syntax error: unexpected end of file
Возможно именно они повлекли за собой проблему из-за которой сгенерированные сборочные файлы Makefile оказались немного пожёванными:
$ make make: *** No rule to make target '<built-in>', needed by 'arg.o'. Stop.
Лезть в дебри замысловатой shell-лапши утилиты makedepend мне решительно не хотелось и я решил тщательно присмотреться к Makefile’ам, в которых выявилась странная закономерность:
arg.o: arg.c arg.o: arg.h arg.o: array.h arg.o: <built-in> arg.o: cmd.h arg.o: <command-line> arg.o: config.h arg.o: EXTERN.h ... array.o: arg.h array.o: array.c array.o: array.h array.o: <built-in> array.o: cmd.h array.o: <command-line> array.o: config.h array.o: EXTERN.h ...
Видимо какая-то утилита неправильно вставила свои аргументы в выхлоп. Взяв в руки топор утилиту sed я решил немного поправить это дело:
$ sed -i '/built-in/d' Makefile $ sed -i '/command-line/d' Makefile
На удивление трюк сработал и Makefile’ы заработали как надо!
Было бы просто невероятно, если бы 32-летний код взял и собрался без каких-либо проблем. К сожалению, чудес не бывает. Изучая дерево исходного кода я наткнулся на файл perl.y, представляющий собой описания грамматики для утилиты yacc, которая в современных дистрибутивах давно заменена на bison. Скрипт, находящийся по пути /usr/bin/yacc, просто вызывает bison в режиме совместимости c yacc. Вот только эта совместимость не является полной и при обработке этого файла сыпется огромная куча ошибок, исправлять которые я не умею да и не очень хочу, ведь есть альтернативное решение, о котором я узнал совсем недавно.
Буквально год или два назад Helio Chissini de Castro, являющийся разработчиком KDE, занимался похожей работой и адаптировал KDE 1, 2 и Qt 1, 2 под современные окружения и компиляторы. Я заинтересовался его работой, скачал исходные коды проектов, но при сборке наткнулся на подобный подводный камень из-за несовместимости yacc и bison, которые использовались для построения древней версии метакомпилятора moc. Впоследствии мне удалось найти решение этой проблемы в виде замены bison на утилиту byacc (Berkeley Yacc), которая оказалась совместимой со старыми грамматиками для yacc и была доступна во многих дистрибутивах Linux.
Простая замена yacc на byacc в системе сборке меня тогда выручила, правда ненадолго, поскольку чуть позже в новых версиях byacc всё-таки сломали совместимость с yacc, отломив отладку, связанную со сущностью yydebug. Поэтому пришлось немного исправлять грамматику утилиты.
Итак, стратегия исправления ошибок построения в файле perl.y была предсказана предыдущим опытом: устанавливаем утилиту byacc, меняем yacc на byacc во всех Makefile, затем вырезаем отовсюду yydebug. Эти действия решили все проблемы с этим файлом, ошибки исчезли и компиляция продолжилась.
Древний код Perl’а пестрил ужасами вроде давным-давно устаревшей и всеми забытой нотации определений функций вида K&R:
format(orec,fcmd) register struct outrec *orec; register FCMD *fcmd; { ... } STR * hfetch(tb,key) register HASH *tb; char *key; { ... } /*VARARGS1*/ fatal(pat,a1,a2,a3,a4) char *pat; { fprintf(stderr,pat,a1,a2,a3,a4); exit(1); }
Подобные особенности встречались, например, в коде Microsoft Word 1.1a, который тоже достаточно древний. Первый стандарт языка программирования «C», под названием «C89» появится лишь через два года. Современные компиляторы умеют работать с таким кодом, а вот некоторые IDE не осиливают разбирать подобные определения и подсвечивают их как синтаксические ошибки, например, раньше таким грешил Qt Creator до того, как парсинг кода в нём перевели на библиотеку libclang.
Компилятор GCC 9.2.0, изрыгая огромное количество предупреждений, взялся компилировать древний код первой версии Perl. Простыни из предупреждений были настолько велики, что для того чтобы добраться до ошибки, приходилось пролистывать несколько страниц выхлопа вверх. На моё удивление, большинство ошибок компиляции были типовыми и в основном связанными с предопределёнными дефайнами, которые играли роль флажков для сборки.
Под дефайном STDSTDIO Larry Wall экспериментировал с какой-то древней и нестандартной библиотекой языка программирования «C», а под дефайном DEBUGGING была отладочная информация с пресловутым yydebug, про который я упоминал выше. По умолчанию эти флажки были включены. Выключив их в файле perl.h и добавив несколько забытых дефайнов мне удалось значительно уменьшить количество ошибок.
Другой тип ошибок — переопределения ныне стандартизированных функций стандартной библиотеки и слоя POSIX. В проекте имеется свой malloc(), setenv() и другие сущности, которые создавали конфликты.
В парочке мест были определены статические функции без объявлений. Компиляторы со временем стали строже относиться к этой проблеме и превратили предупреждение в ошибку. Ну и напоследок парочка забытых хедеров, куда же без них.
На моё удивление патч для кода 32-летней давности получился настолько крошечным, что его можно целиком привести здесь:
diff --git a/malloc.c b/malloc.c index 17c3b27..a1dfe9c 100644 --- a/malloc.c +++ b/malloc.c @@ -79,6 +79,9 @@ static u_int nmalloc[NBUCKETS]; #include <stdio.h> #endif +static findbucket(union overhead *freep, int srchlen); +static morecore(register bucket); + #ifdef debug #define ASSERT(p) if (!(p)) botch("p"); else static diff --git a/perl.h b/perl.h index 3ccff10..e98ded5 100644 --- a/perl.h +++ b/perl.h @@ -6,16 +6,16 @@ * */ -#define DEBUGGING -#define STDSTDIO /* eventually should be in config.h */ +//#define DEBUGGING +//#define STDSTDIO /* eventually should be in config.h */ #define VOIDUSED 1 #include "config.h" -#ifndef BCOPY -# define bcopy(s1,s2,l) memcpy(s2,s1,l); -# define bzero(s,l) memset(s,0,l); -#endif +//#ifndef BCOPY +//# define bcopy(s1,s2,l) memcpy(s2,s1,l); +//# define bzero(s,l) memset(s,0,l); +//#endif #include <stdio.h> #include <ctype.h> @@ -183,11 +183,11 @@ double atof(); long time(); struct tm *gmtime(), *localtime(); -#ifdef CHARSPRINTF - char *sprintf(); -#else - int sprintf(); -#endif +//#ifdef CHARSPRINTF +// char *sprintf(); +//#else +// int sprintf(); +//#endif #ifdef EUNICE #define UNLINK(f) while (unlink(f) >= 0) diff --git a/perl.y b/perl.y index 16f8a9a..1ab769f 100644 --- a/perl.y +++ b/perl.y @@ -7,6 +7,7 @@ */ %{ +#include <stdlib.h> #include "handy.h" #include "EXTERN.h" #include "search.h" diff --git a/perly.c b/perly.c index bc32318..fe945eb 100644 --- a/perly.c +++ b/perly.c @@ -246,12 +246,14 @@ yylex() static bool firstline = TRUE; retry: +#ifdef DEBUGGING #ifdef YYDEBUG if (yydebug) if (index(s,'\n')) fprintf(stderr,"Tokener at %s",s); else fprintf(stderr,"Tokener at %s\n",s); +#endif #endif switch (*s) { default: diff --git a/stab.c b/stab.c index b9ef533..9757cfe 100644 --- a/stab.c +++ b/stab.c @@ -7,6 +7,7 @@ */ #include <signal.h> +#include <errno.h> #include "handy.h" #include "EXTERN.h" #include "search.h" diff --git a/util.h b/util.h index 4f92eeb..95cb9bf 100644 --- a/util.h +++ b/util.h @@ -28,7 +28,7 @@ void prexit(); char *get_a_line(); char *savestr(); int makedir(); -void setenv(); +//void setenv(); int envix(); void notincl(); char *getval();
Отличный результат для 32-летнего кода! Ошибка линковки
undefined reference to `crypt'была исправлена добавлением в Makefile директивы
-lcryptсоответствующей библиотеки libcrypt, после чего я наконец-то получил вожделенный исполняемый файл интерпретатора Perl:
$ file perl perl: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=fd952ceae424613568530b3a2ca88ebd6477e0ae, for GNU/Linux 3.2.0, not stripped
После практически беспроблемной компиляции удача отвернулась от меня. Сразу после запуска собранного интерпретатора Perl’а я получил несколько странных ошибок и Segmentation fault в конце:
$ ./perl -e 'print "Hello World!\n";' Corrupt malloc ptr 0x2db36040 at 0x2db36000 Corrupt malloc ptr 0x2db36880 at 0x2db36800 Corrupt malloc ptr 0x2db36080 at 0x2db36040 Corrupt malloc ptr 0x2db37020 at 0x2db37000 Segmentation fault (core dumped)
Грепнув исходный текст по фразе Corrupt malloc, оказалось, что вместо системного malloc() вызывается какой-то кастомный аллокатор родом из 1982 года. Интересно, что в одном из строковых литералов в его исходном коде прописано Berkeley, а в комментарии рядом — Caltech. Сотрудничество между этими университетами видимо тогда было очень сильно. В общем, я закомментировал этот хакерский аллокатор и пересобрал исходный код. Ошибки порчи памяти исчезли, а Segmentation fault остался. Значит дело было не в этом и теперь нужно расчехлять отладчик.
Запустив программу под gdb я обнаружил что падение происходит при вызове функции создания временного файла mktemp() из libc:
$ gdb --args ./perl -e 'print "Hello, World!\n";' (gdb) r Starting program: /home/exl/perl5/perl -e print\ \"Hello\ World\!\\n\"\; Program received signal SIGSEGV, Segmentation fault. 0x00007ffff7cd20c7 in __gen_tempname () from /usr/lib/libc.so.6 (gdb) bt #0 0x00007ffff7cd20c7 in __gen_tempname () from /usr/lib/libc.so.6 #1 0x00007ffff7d71577 in mktemp () from /usr/lib/libc.so.6 #2 0x000055555556bb08 in main ()
На эту функцию, кстати, ранее ругался линковщик. Не компилятор, а именно линковщик, что меня удивило:
/usr/bin/ld: perl.o: in function `main': perl.c:(.text+0x978c): warning: the use of `mktemp' is dangerous, better use `mkstemp' or `mkdtemp'
Первая мысль, которая наверняка вам тоже пришла в голову — заменить небезопасную функцию mktemp() на mkstemp(), что я именно и сделал. Предупреждение линковщика исчезло, но Segmentation fault в этом месте всё равно остался, только теперь он был в функции mkstemp().
Следовательно теперь нужно очень внимательно посмотреть на кусок кода, который сопряжён с этой функцией. Там я обнаружил довольно странную вещь, которая выделена в этом сниппете:
char *e_tmpname = "/tmp/perl-eXXXXXX"; int main(void) { mktemp(e_tmpname); e_fp = f_open(e_tmpname, "w"); ... }
Получается mktemp() пытается поменять литерал по маске, который находится в секции .rodata, что заведомо обречено на провал. Или всё-таки 32 года назад подобное было допустимо, встречалось в коде и даже как-то работало?
Конечно, замена char *e_tmpname на char e_tmpname[] исправила этот Segmentation fault и я смог получить то, на что убил целый вечер:
$ ./perl -e 'print "Hello World!\n";' $ Hello, World! $ ./perl -e '$a = 5; $b = 6.3; $c = $a+$b; print $c."\n";' $ 11.3000000000000007 $ ./perl -v $Header: perly.c,v 1.0 87/12/18 15:53:31 root Exp $ Patch level: 0
Исполнение из командной строки мы проверили, а что насчёт файла? Я скачал первый попавшийся «Hello World» для языка программирования Perl из интернета:
################# test.pl #!/usr/bin/perl # # The traditional first program. # Strict and warnings are recommended. use strict; use warnings; # Print a message. print "Hello, World!\n";
Затем я попробовал его запустить, но, увы, меня снова ждал Segmentation fault. На этот раз совершенно в другом месте:
$ gdb --args ./perl test.pl (gdb) r Starting program: /home/exl/perl5/perl test.pl Program received signal SIGSEGV, Segmentation fault. 0x00007ffff7d1da75 in __strcpy_sse2_unaligned () from /usr/lib/libc.so.6 (gdb) bt #0 0x00007ffff7d1da75 in __strcpy_sse2_unaligned () from /usr/lib/libc.so.6 #1 0x00005555555629ea in yyerror () #2 0x0000555555568dd6 in yyparse () #3 0x000055555556bd4f in main ()
В функции yyerror() обнаружился следующий интересный момент, привожу оригинальный сниппет:
// perl.y char *tokename[] = { "256", "word", "append", ... // perl.c yyerror(s) char *s; { char tmpbuf[128]; char *tname = tmpbuf; if (yychar > 256) { tname = tokename[yychar-256]; // ??? if (strEQ(tname,"word")) strcpy(tname,tokenbuf); // Oops! else if (strEQ(tname,"register")) sprintf(tname,"$%s",tokenbuf); // Oops! ...
Снова ситуация похожа на ту, про которую я писал выше. Снова модифицируются данные в секции .rodata. Может быть это просто опечатки из-за Copy-Paste и вместо tname хотели написать tmpbuf? Или же действительно за подобным стоит какой-то скрытый смысл? В любом случае, замена char *tokename[] на char tokename[][32] убирает ошибку Segmentation fault и Perl говорит нам следующее:
$ ./perl test.pl syntax error in file test.pl at line 7, next token "strict" Execution aborted due to compilation errors.
Получается, не нравятся ему всякие новомодные use strict, вот что он нам оказывается пытался сказать! Если удалить или закомментировать эти строчки в файле, то программа запускается:
$ ./perl test.pl Hello, World!
Фактически я добился своей цели и заставил древний код родом из 1987 года не только компилироваться, но и работать в современном Linux-окружении. Несомненно, там ещё осталась большая куча различных ошибок Segmentation faults, возможно связанных с размером указателя на 64-битной архитектуре. Всё это можно вычистить посидев несколько вечерков с отладчиком наперевес. Вот только это не слишком приятное и довольно нудное занятие. Ведь изначально этот эксперимент планировался как развлечение на скучный вечер, а не как полноценная работа, которая будет доведена до конца. Имеется ли какая-нибудь практическая польза от проведённых действий? Может быть когда-нибудь какой-нибудь цифровой археолог наткнётся на эту статью и она ему будет полезна. Но в реальном мире даже опыт, извлечённый из подобных изысканий, по моему мнению, не слишком уж ценен.
Если кому-либо интересно, выкладываю набор из двух патчей. Первый исправляет ошибки компиляции, а второй — некоторые ошибки Segmentation fault.
Некоторые полезные ссылки:
P.S. Спешу огорчить любителей деструктивных однострочников, здесь подобное не работает. Возможно версия Perl’а слишком уж старая для таких развлечений.
P.P.S. Всем добра и приятных выходных. Спасибо пользователю kawaii_neko за небольшое исправление.
Update 28-Oct-2019: Пользователь форума LINUX.ORG.RU, использующий ник utf8nowhere, привёл в своём комментарии к этой статье довольно интересные ссылки, информация из которых не только проясняет ситуацию с изменяемыми строковыми литералами, но и даже рассматривает описанную выше проблему использования функции mktemp()! Позволю себе процитировать эти источники, в которых рассказано про различные несовместимости между нестандартизированным «K&R C» и «GNU C»:
Incompatibilities of GCC
There are several noteworthy incompatibilities between GNU C and K&R (non-ISO) versions of C.GCC normally makes string constants read-only. If several identical-looking string constants are used, GCC stores only one copy of the string.
One consequence is that you cannot call mktemp with a string constant argument. The function mktemp always alters the string its argument points to.Another consequence is that sscanf does not work on some systems when passed a string constant as its format control string or input. This is because sscanf incorrectly tries to write into the string constant. Likewise fscanf and scanf.
The best solution to these problems is to change the program to use char-array variables with initialization strings for these purposes instead of string constants. But if this is not possible, you can use the -fwritable-strings flag, which directs GCC to handle string constants the same way most C compilers do.
Источник: Using the GNU Compiler Collection (GCC 3.3) Official Manual.
Флаг компилятора
-fwritable-stringsбыл объявлен устаревшим в GCC 3.4 и окончательно удалён в GCC 4.0.
ANSI C rationale | String literals
String literals are specified to be unmodifiable. This specification allows implementations to share copies of strings with identical text, to place string literals in read-only memory, and perform certain optimizations. However, string literals do not have the type array of const char, in order to avoid the problems of pointer type checking, particularly with library functions, since assigning a pointer to const char to a plain pointer to char is not valid. Those members of the Committee who insisted that string literals should be modifiable were content to have this practice designated a common extension (see F.5.5).Existing code which modifies string literals can be made strictly conforming by replacing the string literal with an initialized static character array. For instance,
char *p, *make_temp(char *str); /* ... */ p = make_temp("tempXXX"); /* make_temp overwrites the literal */ /* with a unique name */can be changed to:
char *p, *make_temp(char *str); /* ... */ { static char template[ ] = "tempXXX"; p = make_temp( template ); }Источник: Rationale for American National Standard for Information Systems, Programming Language C.
Пользователь VarfolomeyKote4ka предложил интересный грязненький хак, который позволяет обойти ошибки Segmentation faults при попытке изменения данных в секции .rodata путём преобразования её в секцию .rwdata. В интернете не так давно появилась очень интересная статья «From .rodata to .rwdata – introduction to memory mapping and LD scripts» за авторством программиста guye1296, как раз рассказывающая о том как провернуть подобный трюк. Для облегчения получения требуемого результата автор статьи подготовил довольно объёмный скрипт для стандартного линковщика ld — rwdata.ld. Достаточно скачать этот скрипт, положить его в корень директории с исходным кодом Perl, подправить флажок LDFLAGS следующим образом:
LDFLAGS = -T rwdata.ld, затем пересобрать проект. В итоге имеем следующее:
$ make clean && make -j1 $ mv perl perl_rodata $ curl -LOJ https://raw.githubusercontent.com/guye1296/ld_script_elf_blog_post/master/rwdata.ld $ sed -i 's/LDFLAGS =/LDFLAGS = -T rwdata.ld/' Makefile $ make clean && make -j1 $ mv perl perl_rwdata $ objdump -s -j .rodata perl_rodata | grep tmp -2 19da0 21233f5e 7e3d2d25 30313233 34353637 !#?^~=-%01234567 19db0 38392e2b 262a2829 2c5c2f5b 7c002400 89.+&*(),\/[|.$. 19dc0 73746465 7272002f 746d702f 7065726c stderr./tmp/perl 19dd0 2d655858 58585858 00323536 00617070 -eXXXXXX.256.app 19de0 656e6400 6c6f6f70 63746c00 66756e63 end.loopctl.func $ objdump -s -j .rwdata perl_rodata | grep tmp -2 objdump: section '.rwdata' mentioned in a -j option, but not found in any input file $ objdump -s -j .rwdata perl_rwdata | grep tmp -2 41d9c0 21233f5e 7e3d2d25 30313233 34353637 !#?^~=-%01234567 41d9d0 38392e2b 262a2829 2c5c2f5b 7c002400 89.+&*(),\/[|.$. 41d9e0 73746465 7272002f 746d702f 7065726c stderr./tmp/perl 41d9f0 2d655858 58585858 00323536 00617070 -eXXXXXX.256.app 41da00 656e6400 6c6f6f70 63746c00 66756e63 end.loopctl.func $ objdump -s -j .rodata perl_rwdata | grep tmp -2 objdump: section '.rodata' mentioned in a -j option, but not found in any input file $ ./perl_rodata -e 'print "Hello, World!\n";' Segmentation fault (core dumped) $ ./perl_rwdata -e 'print "Hello, World!\n";' Hello, World!
Получается, что благодаря такому хаку практически все изменения из второго патча можно просто опустить! Хотя, конечно, привести код к виду который не нарушает стандарты, всё-таки предпочтительнее.
]]>Операционная система Haiku использует гибридное ядро, которое представляет собой реализацию микроядерной архитектуры с возможностью динамической подгрузки необходимых модулей. Оно базируется на форке ядра NewOS, которое было разработано бывшим инженером Be Inc., Travis’ом Geiselbrecht’ом. Сегодня этот разработчик работает в Google над ядром, которое называется Zircon, для новой операционной системы Google Fuchsia, но это уже другая история. Итак, поскольку разработчики Haiku декларируют бинарную совместимость с BeOS, то они вынуждены поддерживать не две привычных всем архитектурных ветки, а три: x86_64, x86 и x86_gcc2. Последняя архитектура представляет собой груз совместимости с компилятором старой версии GCC 2.95. Именно благодаря ей имеется возможность запуска приложений, написанных для оригинальной операционной системы BeOS. К сожалению, из-за этого груза совместимости, разработчики Haiku не могут использовать современные возможности языка программирования C++ в системных API. Тем не менее, установочные образы они подготавливают только для двух архитектур: x86_64 и x86. Всё дело в том, что дистрибутив Haiku для x86 является гибридным: несмотря на то, что все системные компоненты собраны под x86_gcc2 для обеспечения бинарной совместимости, пользователю предоставляется возможность установки или сборки любых современных приложений, которые были сделаны с расчётом на современные компиляторы и архитектуру x86. Дистрибутив Haiku для архитектуры x86_64 является полностью 64-битным и не имеет возможности запуска 32-битных приложений BeOS и Haiku. Однако, совместимость на уровне API имеется, поэтому если у вас есть на руках исходный код приложения под BeOS или Haiku x86, вы без проблем сможете скомпилировать его под Haiku x86_64 и всё должно работать. Образ операционной системы под архитектуру x86_64 рекомендуется для установки на реальное железо, если вам не требуется поддержка каких-либо специфичных приложений BeOS или 32-битных приложений Haiku.
Стоит сказать, что в этой операционной системе имеется частичная поддержка стандарта POSIX. Этот фундамент делает её родственной UNIX-like системам и позволяет легко переносить их программное обеспечение. Основным языком программирования является C++, он активно используется, поскольку публичные API у Haiku в основном преследуют объектно-ориентированную парадигму программирования. Тем не менее, никто не запрещает использовать и язык программирования C, только для большинства случаев придётся писать соответствующие прослойки совместимости. Программный интерфейс операционной системы сгруппирован в отдельные системные фреймворки, которые отвечают за ту или иную возможность, например, за интерфейс или поддержку сети. Это немного похоже на то, что имеется в macOS или во фреймворке Qt. Обязательно нужно отметить, что эта операционная система является однопользовательской, хотя некоторые подвижки в сторону обеспечения многопользовательского режима работы у разработчиков Haiku имеются.
Не могу не поделиться с читателями этой статьи положительным опытом использования продвинутой системы управления окнами приложений, которая имеется в Haiku. На мой взгляд она одна из самых удобных и в своём роде является визитной карточкой этой OS.
Окошки можно скреплять между собой во вкладки, как это сделано в современных браузерах, прикреплять их друг к другу и удобно изменять их размер. Поддерживается простенький тайлинг, перенос контекстов некоторых приложений из одного окна в другое и репликанты. Более подробно обо всех возможностях местной оконной системы можно прочитать в официальной документации, там же описаны все необходимые сочетания клавиш быстрого доступа.
Я не буду писать в этой статье полный обзор всех особенностей и возможностей Haiku, так как те, кому это интересно, легко смогут самостоятельно найти нужную информацию в интернете.
1. Пакеты и репозитории в Haiku
2. Первые шаги: портирование игры Adamant Armor Affection Adventure
3. Доработка существующего порта NXEngine (Cave Story)
4. Портирование игры Gish
5. Проект BeGameLauncher, позволяющий быстро создавать лаунчеры для игр
6. Портирование Xash3D: легендарная игра Half-Life и официальные дополнения
7. Портирование двух частей игры Serious Sam: The First Encounter и The Second Encounter
8. Портирование игры Вангеры (Vangers)
9. Реализация диалогов в библиотеке SDL2 для Haiku
10. Портирование моего форка программы Cool Reader
11. Доработка программы KeymapSwitcher
12. Заключение
По сравнению с оригинальной BeOS, в Haiku появилось значимое нововведение: система управления пакетами, которая включает в себя различные инструменты для получения и установки программного обеспечения из различных источников. Такими источниками могут служить официальные репозитории Haiku и HaikuPorts, неофициальные репозитории и просто отдельные и специально подготовленные HPKG-пакеты. Подобные возможности по установке и обновлению ПО давно известны в мире Unix-like операционных систем, теперь же вся их мощь и удобство успешно добрались и до Haiku, что не может не радовать рядовых пользователей этой операционной системы. Благодаря выстроенной вокруг пакетного менеджера инфраструктуры, теперь любой разработчик может легко портировать новое или доработать уже существующее приложение с открытым исходным кодом, затем добавить результаты своего труда в репозиторий портов программного обеспечения HaikuPorts, после чего они станут доступны всем пользователям Haiku. В итоге получившаяся экосистема напоминает таковую у операционных систем macOS с их Homebrew, FreeBSD с их портами, Windows с MSYS2 или Arch Linux c его AUR‘ом.
Инструмент для сборки пакетов и портирования программного обеспечения, называемый HaikuPorter, поставляется отдельно от операционной системы и устанавливается по небольшому мануалу, расположенному в репозитории на GitHub. После установки этой утилиты с того же GitHub’а скачивается всё дерево рецептов, над которым и работает разработчик. Рецепт представляет собой обычный Shell-скрипт с инструкциями, по которым HaikuPorter и будет собирать требуемый HPKG-пакет. Примечательно, что сам инструмент написан на языке программирования Python 2, тесно взаимодействует со существующей системой управления пакетами, а для фиксации изменений исходного кода ПО и генерации набора патчей, внутри себя использует стандартный инструмент — Git. Именно благодаря подобному стеку технологий делать рецепты для сборки HPKG-пакетов и наборы патчей к ПО в виде patchset-файлов очень удобно и просто. В большинстве случаев мне пришлось использовать всего три команды при работе с HaikuPorter’ом:
alias hp="haikuporter -S -j4 --get-dependencies --no-source-packages" hp libsdl2 hp libsdl2 -c hp libsdl2 -e
Первая команда просто собирает выбранный пакет, вторая команда очищает директорию сборки, а третья создаёт или обновляет набор патчей в соответствии с вашими изменениями, которые были зафиксированы в Git-репозитории рабочей директории посредством коммитов.
Таким образом, чтобы опубликовать какой-либо пакет в репозиторий HaikuPorts и сделать его доступным для всех пользователей Haiku, разработчик должен установить у себя HaikuPorter, развернуть дерево рецептов, локально собрать HPKG-пакет и протестировать его, затем сделать коммит в свой форк дерева рецептов, после чего оформить Pull request на GitHub’е. Опубликованную работу должны рассмотреть разработчики Haiku, после чего они принимают решение влить ваши изменения в репозиторий или же отправить их на доработку. Если изменения приняты, то такой же HaikuPorter, установленный на сборочном сервере, удалённо соберёт пакет и автоматически опубликует его в репозиторий.
В бета-версию «R1/beta1» операционной системы Haiku была добавлена специальная программа HaikuDepot, которая позволяет работать с пакетами и репозиториями через графический интерфейс пользователя, а не через консольные команды в терминале.
Благодаря этому инструменту неискушённые и начинающие пользователи Haiku могут удобно управлять своей пакетной базой. Стоит отметить, что это приложение не просто является GUI-оболочкой над существующим пакетным менеджером, но и реализует дополнительную функциональность. Например, авторизированные пользователи могут выставлять оценки и писать отзывы к доступным для установки пакетам. Кроме того, у HaikuDepot имеется специальный сайт Haiku Depot Web, позволяющий просматривать изменения пакетной базы в интернете или скачивать отдельные HPKG-пакеты.
После того, как я ознакомился с функциональностью операционной системы в виртуальной машине VirtualBox, я решил оценить работу библиотеки SDL2 в ней и портировать на Haiku игру Adamant Armor Affection Adventure, о переносе которой на платформу Android я писал ранее. Сборка программы не потребовала каких-либо изменений исходного кода, я просто установил из репозитория все нужные инструменты, библиотеки, их заголовочные файлы и выполнил следующее:
cmake -DCMAKE_BUILD_TYPE=Release -DGLES=off -DANDROID=off -DCMAKE_C_FLAGS="-D__linux__" -DSDL2_INCLUDE_DIR=`finddir B_SYSTEM_HEADERS_DIRECTORY` -DSDL2_MIXER_INCLUDE_DIR=`finddir B_SYSTEM_HEADERS_DIRECTORY` ../aaaa/src/main/cpp cmake --build .
Поскольку в Haiku имеется POSIX, то дефайны -D__linux__ или -D__unix__ разрешают многие проблемы, связанные с определением платформы. Однако, стоит заметить, что лучше всего отказаться от их использования и реализовывать поддержку Haiku в исходном коде проекта, если существуют подобные проблемы со сборкой. Вызов системной утилиты finddir с определённым аргументом позволяет получить корректный путь к заголовочным файлам для различных архитектур.
Итак, выполнив команды выше, я скомпилировал исполняемый файл, который прекрасно запускался, а игра отлично работала. Я подумал, что было бы классно подготовить самодостаточный HPKG-пакет с игрой и для этого углубился в интернет на поиски необходимой мне информации. Тогда я не знал ни о каких удобных инструментах для портирования программного обеспечения, вроде HaikuPorter’а, о котором я написал в разделе выше, поэтому для осуществления своей цели я решил схитрить и разобрать какой-нибудь системный пакет, чтобы посмотреть как он устроен внутри и сделать по аналогии.
На просторах интернета я нашёл желаемую информацию, после чего распаковал случайный системный пакет с помощью встроенного в местный файловый менеджер архиватора Expander, нашёл файл .PackageInfo, отредактировал его и, в соответствии со структурой своего приложения, подменил файлы. Затем я просто выполнил команды для сборки HPKG-пакета и его установки в систему:
package create -C AAAA/ aaaa.pkg pkgman install aaaa.pkg
К сожалению, запуск игры из меню «Applications» не увенчался успехом. Запустив исполняемый файл в терминале, я получил ошибку, говорившую о невозможности найти файлы данных, которые были необходимы для запуска и работы приложения. При этом, если в терминале перейти в директорию пакета приложения, то всё запускалось нормально. Это натолкнуло меня на мысль о том, что при запуске игры из меню нужно делать принудительное изменение директории приложения. Подобное можно сделать либо Shell-скриптом, либо изменением исходников игры. Я выбрал второй вариант и добавил нечто похожее на этот код:
#ifdef __HAIKU__ // To make it able to start from Deskbar chdir(dirname(argv[0])); #endif
В самое начало стартовой функции main(), что полностью решило данную проблему и пакет получился работоспособным. В комментариях к новости про релиз бета-версии Haiku на сайте Linux.org.ru я скинул ссылку на мой собранный пакет и попросил кого-нибудь направить меня в какие-нибудь активные сообщества пользователей этой операционной системы, после чего лёг спать.
На утро мне на e-mail написал человек, использующий ник 3dEyes**. Как позже оказалось, за этим именем скрывался Герасим Троеглазов — один из активных разработчиков Haiku и автор порта фреймворка Qt для этой операционной системы. Он показал мне репозиторий HaikuPorts и рассказал как пользоваться утилитой HaikuPorter. Кроме того, он написал рецепт для сборки HPKG-пакета игры Adamant Armor Affection Adventure и добавил её в HaikuDepot.
Проанализировав все изменения, внесённые этим разработчиком, я заметил, что в моём собранном вручную пакете были некоторые недостатки, например, не сохранялись настройки, поскольку подмонтированные директории установленных пакетов не имели возможности записи. Эта проблема с записью настроек или сохранений в его пакете изящно решалась с помощью симлинков в специальную директорию, доступную для записи и предназначенную для сохранения пользовательских данных. Ещё у моего пакета не было собственной оригинальной иконки.
Кроме того, я узнал, что в Haiku нет аппаратного ускорения 3D-графики и тот же OpenGL отрисовывается программно с помощью мощностей CPU. Для тяжёлых графических приложений это, конечно, никуда не годится, но для старых игр этого более чем достаточно. Я даже решил специально проверить пакет игры и установил Haiku на свой старый ноутбук, то есть на реальное железо. На моё удивление, картинка Adamant Armor Affection Adventure рендерилась настолько быстро, что если бы мне не сказали про отсутствие аппаратного ускорения, я бы и не заметил того, что рендеринг осуществляется моим процессором.
Ручное создание HPKG-пакетов я отложил до лучших времён и полностью перешёл на использование инструмента HaikuPorter и написание рецептов. Но иногда бывают ситуации, когда требуется ручная пересборка пакета. Например, если HaikuPorter выставил в файле .PackageInfo слишком высокую «ночную» версию Haiku, а пакет нужно протестировать на релизной версии операционной системы. Стоит отметить, что именно благодаря отзывчивости и опыту Герасима я смог разобраться во многих тонкостях создания пакетов для операционной системы Haiku и продолжил свою работу далее.
Я был несказанно удивлён, обнаружив в репозитории HaikuPorts рецепт, который ссылался на мой форк движка NXEngine для игры Cave Story, который я очень давно разбирал в своём блоге. Рецепт и патчи подготовил разработчик по имени Zoltán Mizsei, использующий ник extrowerk и являющийся активным мейнтейнером множества пакетов для Haiku.
Поверхностный анализ, установка пакета и запуск приложения выявил те же проблемы, которые я описывал в предыдущем разделе этой статьи: сохранения игры не работали, настройки тоже не сохранялись, кроме того у пакета не было оригинальной иконки. Я решил исправить эти недочёты и начал работать над патчем, сперва интегрировав все наработки extrowerk’а. Я написал оригинальный Makefile для операционной системы Haiku и поправил запись и сохранение различных пользовательских данных.
Поскольку игра предполагала русскую и английскую версии с разным набором исполняемых файлов и файлов данных, мной было решено сделать общий пакет, объединяющий сразу две версии и автоматически выбирающий нужную на основе выбранного пользователем системного языка. Это было реализовано простейшим Shell-скриптом:
#!/bin/bash if [[ `locale -l` == ru* ]] ; then EXE="`finddir B_SYSTEM_APPS_DIRECTORY`/NXEngine/RUS/Cave Story" else EXE="`finddir B_SYSTEM_APPS_DIRECTORY`/NXEngine/ENG/Cave Story" fi "$EXE" $@
Этот скрипт запускается при выборе пункта игры в меню «Applications» и определяет текущую системную локаль. В том случае, если пользователь выбрал русский язык в качестве системного, запустится русская версия игры, а во всех остальных случаях — английская.
А вот с созданием оригинальной иконки для приложения пришлось изрядно повозиться. Дело в том, что в операционной системе Haiku разрешены только векторные иконки специального формата HVIF, которые устанавливаются в качестве атрибутов файловой системы Be File System. В официальной документации существует два больших мануала, посвящённых созданию собственных иконок для приложений: первый мануал описывает стилистику рисовки и дизайн, а второй мануал подробно рассказывает как пользоваться системной программой Icon-O-Matic, предназначенной для создания иконок.
Icon-O-Matic позволяет импортировать простейшие SVG-файлы и экспортировать получившуюся иконку в необходимый для HaikuPorter’а формат, называемый HVIF RDef и представляющий собой тот же HVIF, но преобразованный в текстовый вид. RDef-файлы могут содержать не только изображения, но и дополнительную информацию, например, версию приложения и его описание. Чем-то эти файлы напоминают RES-файлы, используемые в Windows. Следующие команды в рецепте компилируют RDef-файлы и устанавливают получившийся результат в специальные атрибуты:
rc nxengine-launcher.rdef resattr -o "$appsDir/NXEngine/Cave Story" nxengine-launcher.rsrc addResourcesToBinaries $sourceDir/build/nxengine-rus.rdef "$appsDir/NXEngine/RUS/Cave Story"
Кроме того, в рецептах определена функция addResourcesToBinaries, позволяющая автоматизировать эту работу. Проблема с программой Icon-O-Matic имеется одна, но очень серьёзная: те SVG-файлы, которые сохраняет популярный векторный редактор Inkscape, либо не открываются, либо импортируются без поддержки некоторых необходимых возможностей, например, градиентов. Поэтому приключенческий квест с конвертированием растровых изображений в векторные через использование различных платных и бесплатных online- и offline-конверторов, а потом открытием получившихся SVG-файлов в программе Icon-O-Matic, я с треском провалил. Позже я решил проблему открытия SVG-файлов и нашёл обходной путь, но об этом я напишу ниже. А пока я решил воспользоваться стандартными возможностями программы Icon-O-Matic и нарисовать иконку самостоятельно. Спустя полчаса работы по усердному копированию пикселей у меня получилось следующее художество:
Да, я использовал векторный редактор для создания изображения в жанре Pixel Art. На мой дилетантский взгляд человека, который слабо разбирается в искусстве, получилось вполне неплохо. Я сохранил эту иконку в нужном формате, подготовил все изменения, обновил рецепт и отправил всё в репозиторий HaikuPorts. Получившиеся пакеты я отправил на всякий случай и на фанатский сайт игры Cave Story (Doukutsu Monogatari), администрация которого добавила операционную систему Haiku в раздел загрузок.
Следующим проектом, который я решил перенести на Haiku, стала игра Gish, которую ранее я уже переносил на Android. В репозитории HaikuPorts был рецепт для недоделанной свободной реализации игры под названием Freegish, поэтому я решил добавить туда ещё и оригинальную игру, но без файлов данных, так как они, в отличие от движка, поставляются отдельно и вовсе не бесплатны.
Никаких особых проблем с портированием этой игры у меня не возникло. Исполняемый файл собрался сразу же после выполнения следующих команд сборки:
cmake gish/src/main/cpp/ \ -DGLES=0 \ -DANDROID=0 \ -DSDL2_INCLUDE_DIR=`finddir B_SYSTEM_HEADERS_DIRECTORY` \ -DCMAKE_C_FLAGS="`sdl2-config --cflags` -D__linux__" \ -DCMAKE_BUILD_TYPE=Release cmake --build .
Далее я реализовал возможность запуска игры из меню «Applications» и обеспечил поддержку сохранения пользовательских данных в доступную для записи и предназначенную для этого директорию:
char* getHaikuSettingsPath() { char path[PATH_MAX]; find_directory(B_USER_SETTINGS_DIRECTORY, -1, false, path, sizeof(path)); strcat(path, "/Gish/"); return strdup(path); }
Функция getHaikuSettingsPath() с помощью функции find_directory() из Haiku API формирует полный путь до необходимой мне директории.
Оставалось решить следующий вопрос: каким образом пользователь должен выбирать директорию с оригинальными файлами игры Gish? Проблему можно было попытаться решить с помощью Shell-скриптов и системной утилиты alert, но я решил подойти к этой проблеме более основательно и реализовать удобный GUI-лаунчер, используя Haiku API и фреймворк Interface Kit.
Мой проект BeGameLauncher было решено писать на языке C++ старого стандарта 1998 года, используя родные средства операционной системы для создания приложений с графическим интерфейсом пользователя. Так как названия многих программ для Haiku и BeOS начинаются с перефикса «Be», мной было тоже решено выбрать именно такое название для проекта. Начать я решил с ознакомления с фреймворком Interface Kit, который входит в состав Haiku API. Кроме достаточно подробной документации на официальном сайте Haiku, я нашёл два просто отличных курса уроков от DarkWyrm, которые позволяют быстро понять начинающему разработчику как работают те или иные системные классы. Первый курс называется Learning to Program with Haiku и в самом начале затрагивает основы языка программирования C++, что будет очень полезно начинающим программистам. Второй курс называется Programming With Haiku и предназначен для тех, кто уже знаком с C++ и имеет базовые знания этого языка. Оба курса рассказывают о самых различных аспектах Haiku API и поэтому будут очень полезны любому человеку, который хочет начать создавать приложения для этой операционной системы.
Прочитав по диагонали этот отличный материал, я составил общее впечатление о Haiku API и начал обдумывать свои дальнейшие действия. У меня уже имелся небольшой опыт разработки прикладных приложений с использованием фреймворка Qt, который тоже написан на языке программирования C++ и использует объектно-ориентированную парадигму построения программ. Так вот, Haiku API очень сильно на него похож, за исключением отсутствия системы сигналов и слотов, поэтому я буду часто проводить некоторые параллели и сравнения с Qt. Кроме того, стоит отметить распространённое в Haiku API использование принципа Event-driven programming, который позволяет взаимодействовать различным сущностям между собой посредством передачи событий или сообщений. Аналогом класса QEvent здесь является класс BMessage, вокруг которого и построена система взаимодействия объектов. Экземпляр класса BMessage в общем случае получает уникальное число, которое позволяет идентифицировать отправителя и его действие в общем фильтре событий.
Для моего проекта нужно было выбрать подходящие классы Haiku API, которые позволяли бы реализовать задуманную функциональность. Во-первых, для запуска внешнего приложения, нужно было найти аналог класса QProcess или POSIX-функции execve(), которая, к слову, тоже отлично работает в операционной системе Haiku, однако я решил, что использовать родные средства будет предпочтительнее, но на всякий случай оставил возможность запуска приложений и через POSIX-функцию. Класс BRoster, занимающийся межпроцессным взаимодействием, отлично подходил для этой цели. В нём нашёлся подходящий метод Launch(), позволяющий задать путь до исполняемого файла и передать ему аргументы. Поскольку лаунчер должен иметь возможность сохранения некоторых параметров, например, выбранной пользователем директории с файлами данных игры, мне нужен был класс, который занимается всем этим. В Qt такой класс имеет название QSettings, а в Haiku API, как мне подсказал Герасим, имеется уже знакомый мне класс BMessage, который имеет очень полезную особенность. Всё дело в том, что информацию этого класса можно легко сериализовать и, например, сохранить на диск. Это очень удобно и часто используется для записи каких-либо пользовательских данных в программах, поэтому именно этот класс я и выбрал для сохранения настроек в своём проекте реализации лаунчеров. К сожалению, в Haiku API не нашлось аналога класса QDebug, поэтому требуемый мне отладочный вывод в процессе разработки я просто отправлял в stderr, средствами функции fprintf() из стандартного языка программирования C:
// .h #if __cplusplus >= 201103L #define BeDebug(...) fprintf(stderr, __VA_ARGS__) #else extern void BeDebug(const char *format, ...); #endif // __cplusplus == 201103L // .cpp #if __cplusplus < 201103L #include <cstdarg> void BeDebug(const char *format, ...) { va_list args; va_start(args, format); vfprintf(stderr, format, args); va_end(args); } #endif
Эту функцию я обернул в удобную мне сущность BeDebug(), которая в зависимости от выбранного стандарта языка является либо макросом, либо тоже функцией. Так было сделано из-за того, что C++98 не поддерживает макросы с переменным количеством аргументов.
Ещё во фреймворке Qt имеется полезный класс QMessageBox, через который можно создать модальный диалог с какой-либо информацией, на которую должен обратить внимание пользователь, например, на ошибку или предупреждение. В Haiku API для этих целей имеется класс BAlert, реализация которого несколько отличается от того, что доступно в Qt. Например, объект этого класса обязательно должен быть создан в куче, а не на стеке, поскольку после какого-либо действия пользователя он должен удалить сам себя. Что же касается других классов графического интерфейса, то здесь у меня не возникло абсолютно никаких трудностей и всё необходимое я нашёл без каких-либо проблем.
Теперь мне следовало подумать о простенькой архитектуре проекта. Я решил остановиться на создании статической библиотеки, в которой бы имелось два класса, предназначенных для унаследования от них собственных производных классов. Первый и самый важный класс, BeLauncherBase, отвечает за создание главного окна лаунчера, передачу всех пользовательских параметров и предоставляет возможность добавления собственных GUI-элементов. Второй класс, BeAboutWindow, просто отвечает за открытие диалога «О программе…» с информацией, которая показывается в отдельном окне. Таким образом, программисту для создания своего лаунчера, например, для игры Gish, требуется сделать два простых действия:
class GishAboutWindow : public BeAboutWindow { ... }; class GishLauncher : public BeLauncherBase { ... }; int main(void) { BeApp *beApp = new BeApp(SIGNATURE); GishLauncher *gishLauncher = new GishLauncher(BeUtils::GetPathToHomeDir()); beApp->SetMainWindow(gishLauncher); beApp->Run(); delete beApp; beApp = NULL; return 0; }
Во-первых, создать подходящую стартовую функцию main(), а во-вторых просто унаследоваться от двух вышеперечисленных классов и реализовать в них необходимые методы. После этого компилируем полученный C++-файл с линковкой к моей статической библиотеке и наш лаунчер для игры Gish готов.
Далее я задумался о том, каким образом мне передавать из своего лаунчера параметры в сам движок или в исполняемый файл игры. Я увидел только два пути решения этой проблемы. Первый путь заключался в изменении переменных окружения. На практике лаунчер после нажатия на кнопку «Run» просто помещает все параметры в переменные окружения посредством вызовов функции setenv(), а движок игры потом читает эти параметры с помощью функции getenv(), что выглядит достаточно просто. Единственная проблема, которая могла здесь возникнуть, находилась в классе BRoster и его методе Launch(): я не знал, унаследует ли запускаемое с помощью этого класса приложение все те переменные окружения, которые были выставлены в лаунчере. После небольшого эксперимента наследование переменных окружения подтвердилось и этот способ я полностью реализовал в своём проекте. Второй путь решения проблемы заключался в задании специальных параметров командной строки. На практике лаунчер просто складывал все настройки в соответствующие аргументы и вызывал с ними исполняемый файл приложения. А вот движок игры уже должен был самостоятельно их обработать, что создавало некоторые сложности. Например, если игра не предполагала возможность задания пути до игровых файлов через параметры командной строки, то нужно было модифицировать парсер аргументов в самом движке. Несмотря на эти проблемы, я реализовал и этот способ взаимодействия и в итоге получил отличную возможность совмещать всё вместе. Это позволило мне создавать в некоторых лаунчерах строку задания пользовательских аргументов.
Когда всё было спроектировано, я решил выбрать сборочную систему для своего проекта. Рассматривалось только два варианта: Makefile «на стероидах» и CMake. В первом случае, разработчики операционной системы Haiku подготовили удобный пакет makefile-engine, в котором собрали все необходимые возможности, с которыми столкнулся бы разработчик, начав писать приложение на Haiku API, например, автоматическую генерацию переводов и компиляцию ресурсов приложения. Но я не из тех, кто ищет лёгкие пути, поэтому я выбрал CMake и перенёс в него некоторые наработки из пакета makefile-engine. В итоге получившийся монструозный сборочный скрипт вы можете посмотреть в репозитории проекта, ссылку на который я оставлю ниже.
Хотелось бы написать пару слов о локализации приложений. Во фреймворке Qt для этого имеется удобная функция-обёртка tr(), две вспомогательные утилиты lrelease и lupdate, которые занимаются генерацией файлов перевода. В комплекте с фреймворком доступна даже специальная программа Qt Linguist с удобным графическим интерфейсом пользователя, предназначенная для переводчиков. В Haiku API инструменты для локализации приложений менее удобные и более архаичные. Строки, которые нужно перевести, предлагается оборачивать в специальный макрос B_TRANSLATE(), а в исходный файл добавлять определение B_TRANSLATION_CONTEXT, которое отделяет одну группу переводимых строк от другой. После этого требуется выполнить очень странную вещь: натравить препроцессор компилятора с флагом
-DB_COLLECTING_CATKEYSна абсолютно все исходные файлы проекта, сделать какую-то магию с помощью утилиты grep и в итоге получить PRE-файл громадного размера. Именно с этим файлом и будет работать утилита collectcatkeys, которая уже создаст человекочитаемые и удобные для редактирования переводчику CATKEYS-файлы. После локализации строк необходимо воспользоваться утилитой linkcatkeys, которая добавляет переводы в ресурсы исполняемого файла. Таким образом, при выборе определённого системного языка приложение отображает переведённые строки. Странно, но в документации Haiku API о локализации приложений содержится очень мало информации. Однако, на официальном сайте я нашёл отличную статью Localizing an application, в которой подробно рассмотрены многие аспекты переводов приложений для этой операционной системы. Как я понял, в оригинальном BeOS не было фреймворка Locale Kit и он был добавлен уже только в Haiku.
Следующим моим шагом стал выбор среды для разработки приложений на языке программирования C++. Благодаря тому, что на Haiku был портирован фреймворк Qt, в репозитории HaikuPorts доступны такие IDE, как Qt Creator и KDevelop. Кроме того, имеется порт JVM, что позволяет использовать IDE, написанные на языке программирования Java, например, NetBeans или IntelliJ IDEA. Я остановил свой выбор на среде разработки Qt Creator, тем более в её последних версиях имеется качественный разбор кода с помощью парсера LibClang, который работает на порядок точнее и быстрее стандартного парсера.
В плане общеизвестных и кроссплатформенных IDE в Haiku всё хорошо. Но что насчёт эксклюзивных решений? Я не могу не упомянуть очень интересный проект, автором которого является DarkWyrm и который в настоящее время поддерживает Adam Fowler, он называется Paladin. Эта программа превращает доступный в дистрибутиве операционной системы продвинутый текстовый редактор Pe практически в настоящую IDE.
С помощью встроенного в оконную систему Haiku тайлинга можно прикрепить окно Paladin сбоку редактора Pe и добавить терминал. Ещё в репозитории HaikuPorts имеется удобный редактор текста Koder, напоминающий собой популярную программу Notepad++ для Windows и так же базирующийся на наработках проекта Scintilla. Для своего приложения я создал проектный PLD-файл и теперь любой разработчик, который пользуется Paladin IDE, может без проблем открыть в этой программе мой проект.
Когда среда разработки Qt Creator была настроена и готова к работе, я начал реализовывать все задуманные возможности. Первая проблема, с которой я столкнулся, была связана с масштабированием контролов при изменении размера системного шрифта. Изначально в BeOS весь код размещения GUI-элементов задавался явно в координатах. Это было очень неудобно, многословно и создавало огромный ворох проблем, например, при том же изменении размера шрифта вся форма приложения разъезжалась и становилась непригодной к использованию. К счастью, в Haiku попытались решить эту проблему и добавили программный интерфейс Layout API, который является частью фреймворка Interface Kit.
Это нововведение полностью решало мою проблему с позиционированием контролов и я переписал приложение с использованием Layout API, что серьёзно сократило длину кода в некоторых местах. На официальном сайте Haiku я нашёл цикл интересных статей Laying It All Out, в которых как раз рассказываются причины того, почему был создан этот программный интерфейс и показаны примеры его использования.
Ещё одну проблему обозначил Герасим, когда попробовал воспользоваться моей библиотекой для создания лаунчера к игре, которую он портировал. Дело было в том, что я часто обращался к исходному коду самой операционной системы Haiku для реализации различной функциональности. В частности, пример использования метода Launch() у объекта класса BRoster я обнаружил именно там. Проблема проявлялась в том, что этот пример оказался некорректным и движок игры, которую портировал Герасим, не мог правильно распарсить заданные лаунчером аргументы. Глубже изучив исходный код Haiku мне удалось выяснить, что первый аргумент, который должен содержать полный путь до исполняемого файла, в случае с методом Launch() не требуется задавать явно, так как он будет задан автоматически.
// Error const char* args[] = { "/bin/open", fURL.String(), NULL }; be_roster->Launch(&ref, 2, args); // Good const char* args[] = { fURL.String(), NULL }; be_roster->Launch(&ref, 1, args); // See "src/kits/app/Roster.cpp", BRoster::ArgVector::Init() method: if (error == B_OK) { fArgs[0] = fAppPath.Path(); // <= Here if (argc > 0 && args != NULL) { for (int i = 0; i < argc; i++) fArgs[i + 1] = args[i]; if (hasDocArg) fArgs[fArgc - 1] = fDocPath.Path(); } // NULL terminate (e.g. required by load_image()) fArgs[fArgc] = NULL; }
В документации на метод Launch() ничего не сказано про то, что первый аргумент задавать не требуется, наверное именно поэтому разработчик написал этот код некорректно. Я исправил эту ошибку в своём проекте и проблема Герасима разрешилась сама собой. Но что насчёт этой небольшой ошибки в самой операционной системе Haiku? Я решил исправить и её. К счастью, это оказалось сделать ну очень просто! Нужно авторизоваться с помощью GitHub на Gerrit-ресурсе Haiku Code Review, добавить свой публичный SSH-ключ, форкнуть исходный код Haiku, создать коммит с исправлением и отправить получившийся патч на Code review привилегированным разработчикам:
git clone ssh://EXL@git.haiku-os.org/haiku --depth=1 -b master && cd haiku git commit git push origin master:refs/for/master
Если нужно обновить уже отправленные патчи, то перед отправкой изменённых или новых коммитов обязательно добавляем в конец commit-сообщения тот ID, который выдал нам сервис Haiku Code Review. После того, как патч отправлен, разработчики Haiku должны его подтвердить, отклонить или отправить на доработку. В моём случае исправление было принято сразу и этот небольшой недочёт теперь устранён везде. Если вам требуется протестировать ваши патчи перед отправкой в репозиторий, то вы можете попробовать с помощью утилиты jam, которая является форком сборочной системы Perforce Jam и используется для сборки всей кодовой базы операционной системы Haiku, скомпилировать отдельное приложение. В репозитории исходного кода имеется файл ReadMe.Compiling.md, который поможет вам разобраться со всеми премудростями компиляции.
Дорабатывая свой проект, я нашёл причину по которой программа Icon-O-Matic не открывает SVG-файлы, созданные с помощью векторного редактора Inkscape. Всё дело в том, что Icon-O-Matic не умеет обрабатывать атрибут viewBox, однако, если найти простой SVG-файл без этого атрибута, отредактировать его с помощью Inkscape и сохранить как Plain SVG file, то он откроется и в программе Icon-O-Matic. Поэтому я положил в свой репозиторий такой специально подготовленный SVG-файл, который можно редактировать и который будет открываться в Icon-O-Matic без проблем. Дополнительно я добавил в ReadMe-файл проекта небольшую инструкцию о том, как создавать иконки для своих лаунчеров с помощью Inkscape.
Код своего проекта я решил проверить самыми различными статическими анализаторами, но никаких серьёзных проблем они не нашли. А я вот позже нашёл одну проблему, которую не смогли обнаружить они. Дело в том, что статический метод GetBitmap() класса BTranslationUtils мог вернуть NULL:
// Somewhere fBitmap = BTranslationUtils::GetBitmap(B_PNG_FORMAT, fIndex); void BeImageView::Draw(BRect rect) { // Fail const BRect bitmapRect = fBitmap->Bounds(); ... }
И в методе Draw() я по невнимательности забыл проверить поле класса fBitmap на валидность. Поэтому приложение ожидаемо падало, если не находило определённую картинку, а по плану должно было нарисовать красный квадрат вместо этого. Эту историю я рассказал к тому, что статические анализаторы далеко не панацея и внимательность при работе с кодом на языке программирования C++ требуется в любом случае.
Исходный код проекта BeGameLauncher и все свои наработки я выкладываю в репозиторий на GitHub. Надеюсь, эта программа окажется кому-нибудь полезной и может быть станет неким учебным пособием в качестве простого приложения для Haiku:
https://github.com/EXL/BeGameLauncher
Небольшой совет для тех, кто будет использовать мой лаунчер в своих рецептах для репозитория HaikuPorts. Если вы хотите скрыть исполняемый файл игры из списка приложений Haiku, которые читают некоторые программы, и оставить там только лаунчер, вы можете воспользоваться следующим трюком:
settype -t application/x-vnd.Be-elfexecutable $appsDir/Gish/engine/Gish rc $portDir/additional-files/gish.rdef -o gish.rsrc resattr -o $appsDir/Gish/engine/Gish gish.rsrc
Это позволит исключить возможность запуска исполняемых файлов без переданных лаунчером параметров из различных программ вроде QuickLaunch, которые занимаются быстрым запуском приложений. При этом ваша оригинальная иконка на исполняемом файле будет сохранена.
Проект Xash3D представляет собой свободную реализацию движка GoldSrc, который используется в игре Half-Life и в её официальных дополнениях. За разработкой Xash3D стоит отечественный программист Дядя Миша, который до сих пор координирует его развитие и улучшение. Чуть позже к проекту присоединились другие разработчики, которые сделали форк FWGS Xash3D, с поддержкой огромного количества операционных систем, отличных от Windows. Сегодня ключевыми программистами проекта FWGS Xash3D являются mittorn и a1batross, последний человек был активным участником некогда популярного форума MotoFan.Ru, который я до сих пор администрирую в своё свободное время.
Я задался вопросом: почему бы не портировать этот движок на Haiku, добавив в проект Xash3D поддержку такой интересной операционной системы, а пользователям Haiku дать возможность поиграть в легендарный Half-Life, игру всех времён и народов? Дело оставалось за малым — требовалось незамедлительно начать работу по портированию и в случае успеха опубликовать результаты этой работы.
Потратив несколько часов на изучение структуры проекта и тех частей кода, которые отвечают за поддержку различных платформ, я начал вносить изменения в движок Xash3D, чтобы обеспечить возможность поддержки операционной системы Haiku. По-старинке, я задефайнил компилятору -D__linux__ и попытался собрать исполняемый файл и кучу библиотек. На удивление дело пошло достаточно быстро и уже к вечеру, пробросив файлы данных для игры, у меня получилось запустить Half-Life и доехать на поезде до начальной станции в Black Mesa.
Благодаря тому, что проект использует кроссплатформенную библиотеку SDL2, портирование движка очень сильно упрощается, так как не нужно писать каких-либо кусков кода, которые зависят от платформы, например: вывод звука, создание окна с OpenGL-контекстом, или обработку событий ввода. Всё это уже реализовано в библиотеке SDL2 и готово к использованию. Небольшая проблема возникла с поддержкой сети, потому что в Haiku имеется отдельная библиотека, реализующая сетевой стек, соответственно, её требовалось прилинковать к движку.
Проект по созданию лаунчеров, про который я написал чуть выше, мне очень сильно пригодился. С помощью наследования C++-классов я серьёзно расширил его функциональность и реализовал возможность выбора различных дополнений игры:
Идея была следующей: определить три переменных окружения, которые бы позволили гибко настраивать движок игры на запуск определённого дополнения. При этом было бы полезно дать пользователю поиграться с различными аргументами исполняемого файла и оставить возможность портативного запуска движка, когда он лежит просто в директории с требуемыми файлами данных. Итак, первая переменная окружения XASH3D_BASEDIR отвечает за директорию с файлами игры, которую выбирает пользователь из лаунчера. Вторая переменная XASH3D_GAME отвечает за то, какое дополнение выбрал для запуска пользователь в лаунчере. А вот третья переменная XASH3D_MIRRORDIR, пригодится лишь продвинутым пользователям. Она позволяет зеркалировать системную директорию Xash3D в любое доступное для записи пользователю место на диске. Таким образом, человеку, который хочет выпустить свою игру-дополнение на движке Xash3D под Haiku требуется просто собрать из исходного кода своего проекта несколько динамических библиотек для разных архитектур:
И затем положить их в соответствующие директории своего дополнения. Для своего порта Xash3D я решил предкомпилировать библиотеки популярных дополнений к игре Half-Life, а именно Blue Shift и Opposing Force, что позволит пользователям просто скачать их файлы данных, выбрать директорию и начать игру без каких-либо компилирований библиотек.
В процессе портирования движка Xash3D я столкнулся с некоторыми забавными проблемами. Оказывается, для определения длины сообщения справки по аргументам исполняемого файла, которая генерируется при передаче параметра
--help, в движке был использован предустановленный размер константы MAX_SYSPATH, которая является псевдонимом другой константы MAX_PATH, значение которой уже берётся из Haiku API. Так вот, я долго не мог понять, почему же эта справка выдаётся неполной и обрезается в самом интересном месте. Сначала я грешил на то, что каким-то странным образом к стандартному потоку вывода ошибок stderr подключилась буферизация и даже пытался принудительно её отключить. Спустя какое-то время я вспомнил, что меня удивил очень маленький размер константы MAX_PATH в операционной системе Haiku. Эта константа предполагает размер пути всего в 1024 байт. Моя догадка полностью оправдала себя, как только я увеличил размер сообщения до стандартных 4096 байт, проблема разрешилась. Из этой забавной истории следует сделать следующий вывод: не стоит использовать константу MAX_PATH в массивах символов, которые никак не связаны с файловыми путями.
Ещё одной проблемой был вылет при использовании функциональности самого движка для выбора дополнения игры. Оказалось, что при выставленном дефайне XASH_INTERNAL_GAMELIBS клиентская библиотека загружалась не один, а два раза. Что и повлекло за собой подобную проблему. Как мне разъяснил a1batross, так было сделано для того, чтобы была возможность статически прилинковать библиотеку OpenVGUI к клиентской библиотеке. В моём порте Xash3D на Haiku эта библиотека никак не используется, поэтому я просто ушёл от использования дефайна XASH_INTERNAL_GAMELIBS и зарепортил этот баг разработчикам движка.
Далее я наткнулся на невозможность открытия встроенного в Haiku браузера WebPositive при нажатии на ссылки внутри запущенной в Xash3D игры. При этом проблема была действительно странной, так как при запуске движка из терминала браузер открывался, а вот при запуске с помощью лаунчера он отказывался это делать. Немного изучив код я нашёл там вызов execve(), который я попробовал заменить на system(), после чего браузер стал открываться без каких-либо проблем.
Движок Xash3D при возникновении ошибок активно использует вызовы функций SDL_ShowSimpleMessageBox() и SDL_ShowMessageBox(), вот только текущий порт библиотеки SDL2 для Haiku не поддерживает создание этих диалогов. В нашей версии библиотеки просто отсутствует эта функциональность. Но об исправлении этой проблемы я расскажу ниже.
Стоит ещё отметить, что перед моим переносом движка Xash3D на Haiku, Герасим Троеглазов реализовал в SDL2 захват курсора мыши; до этого играть в 3D-игры было практически невозможно. Чуть позже он же исправил хитрый баг, при котором перемещение игрока в пространстве постепенно замедлялось, а игра начинала жутко тормозить. Оказывается, дело было в том, что по умолчанию события курсора мыши передавались со всей его историей передвижения по экрану. Соответственно, эта история в процессе игры быстро раздувалась и всё начинало сильно тормозить. Отключение подобной возможности в порте SDL2 на Haiku решило эту проблему и в Half-Life теперь можно играть без особых проблем. Хотя, отсутствие 3D-ускорения на слабом железе даёт о себе знать. И если игра прилично работает в окне и вообще не тормозит, то в полноэкранном режиме значительно снижается FPS. Но тут поможет лишь добавление в видеодрайвера операционной системы аппаратного ускорения хотя бы для тех GPU, которые встроены в популярные процессоры Intel.
Все изменения исходного кода я отправил разработчикам проекта FWGS Xash3D, которые приняли их в репозиторий, а пакеты с этим движком уже давно доступны в HaikuPorts и в программе HaikuDepot для любого пользователя Haiku.
Недавно разработчики из компании Croteam выложили исходный код движка Serious Engine, который используется в играх серии Serious Sam: The First Encounter и The Second Encounter. Я решил заняться его портированием на операционную систему Haiku, скачал исходный код и начал работу.
Сборка исполняемого файла после внесённых изменений обошлась без каких-либо проблем, а вот запустить игру так просто не удалось из-за того, что ошибки сыпались в диалоги SDL2, реализация которых отсутствует в версии этой библиотеки для Haiku. Поэтому пришлось брать в руки проверенный временем стандартный поток вывода ошибок stderr и потихоньку разбираться в проблемах, которые оказались в основном в отсутствии требуемых файлов данных игры.
Разложив скачанные файлы по требуемым директориям я без проблем смог запустить вторую часть этой замечательной игры и даже немного побегал по красивейшим джунглям. Несмотря на отсутствие 3D-ускорения, процессор вытягивает графические прелести игры, если запускать её в окне, а не в полноэкранном режиме. Работает этот движок, конечно, куда с меньшим FPS, чем движок Xash3D, про который я писал выше, но и графика здесь современнее и лучше. После небольших манипуляций удалось запустить и первую часть игры, которая требует другой исполняемый файл и другой набор динамических библиотек. На удивление, она заработала немного быстрее, видимо графика в ней не такая требовательная. Полазив по настройкам движка, я обнаружил огромное количество графических параметров, позволяющих значительно снизить нагрузку на процессор, что в случае с Haiku оказалось очень полезным.
Я решил сделать один пакет сразу для двух частей игры, переключение между которыми будет осуществляется просто выбором директории с соответствующим набором файлов данных. Например, если пользователь в лаунчере выбирает директорию с файлами игры Serious Sam: The First Encounter, то запускается соответствующий исполняемый файл и подгружается соответствующий набор динамических библиотек. А если он выберет каталог с файлами игры Serious Sam: The Second Encounter, то лаунчер соответственно запустит уже другой исполняемый файл, который подгрузит свой набор разделяемых библиотек.
К сожалению, без проблем не обошлось. Повторное изменение разрешения видеорежима в игре приводило к падению всего движка. При этом в моём дистрибутиве Linux этого вылета не было. Я потратил очень много времени на локализацию проблемы и на её устранение. Оказалось, всё дело было в том, что при каждом изменении разрешения разрушалось и снова создавалось окно SDL_Window, при этом OpenGL-рендерер вовремя не мог переключиться и пытался что-то там рисовать в разрушенном окне. Такие выкрутасы порт библиотеки SDL2 на Haiku не позволял проворачивать. Все простые попытки решения этой проблемы не помогали и мне пришлось серьёзно влезть в логику и изменить поведение таким образом, чтобы окно при смене разрешения не разрушалось, а просто изменялись его параметры. Это помогло убрать вылет, но добавило дополнительное ограничение: теперь, чтобы активировать полноэкранный режим, требуется перезапустить движок.
Ещё одной проблемой было отсутствие музыки в игре. При этом на Linux, опять же, эта проблема не проявлялась. Исследуя исходный код движка, я обнаружил что воспроизведение музыки зависит от библиотеки libvorbisfile, но сам движок при этом не линкуется с ней, а использует системную функцию dlopen(), чтобы скормить этой библиотеке поток OGG-аудиофайла. Проблема заключалась в том, что на Haiku эта библиотеку движок не мог найти, так как симлинка на файл библиотеки без обозначения версии не было.
void CUnixDynamicLoader::DoOpen(const char *lib) { // Small HACK for Haiku OS (: #ifdef __HAIKU__ static int vorbis_cnt = 3; char path[PATH_MAX]; char libpath[PATH_MAX]; find_directory(B_SYSTEM_LIB_DIRECTORY, -1, false, libpath, PATH_MAX); if (strstr(lib, "vorbis")) { snprintf(path, sizeof(path), "%s/libvorbisfile.so.%c", libpath, char(vorbis_cnt + '0')); vorbis_cnt++; lib = path; } #endif // fprintf(stderr, "dlopen => %s\n", lib); module = ::dlopen(lib, RTLD_LAZY | RTLD_GLOBAL); SetError(); }
Небольшой трюк, который подставлял в функцию полный путь до требуемой библиотеки, оказался вполне рабочим решением. А поскольку библиотека при её отсутствии ищется движком несколько раз, я на будущее оставил возможность подгрузки следующей мажорной версии. Надеюсь, они не сломают в ней API.
Следующая проблема, с которой я столкнулся, заключалась в невозможности определения частоты процессора на архитектуре x86, хотя на x86_64 всё работало нормально. При запуске на x86 движок просил выставить переменную окружения с именем SERIOUS_MHZ и задать в ней соответствующую частоту, что меня очень сильно удивило. Я попробовал это сделать и игра действительно запустилась, но почему-то работала слишком медленно. Полазив по исходному коду игры, я долго не мог найти источник проблемы и даже написал кусочек кода, который с помощью Haiku API получает правильную частоту процессора и подставляет её в движок игры, вот так он выглядел:
#include <kernel/OS.h> #include <stdio.h> ... uint64 cpuFreq = 0; uint32 count = 0; get_cpu_topology_info(NULL, &count); if (count != 0) { cpu_topology_node_info *topology = new cpu_topology_node_info[count]; get_cpu_topology_info(topology, &count); for (uint32 i = 0; i < count; ++i) { if(topology[i].type == B_TOPOLOGY_CORE) { cpuFreq = topology[i].data.core.default_frequency; } } delete[] topology; } fprintf(stderr, "%llu\n", cpuFreq);
Но это не помогало. Тогда я проверил логи движка на x86_64 и увидел, что там у CPU частота вообще определяется в 1 MHz, но всё прекрасно работает. Продолжив исследовать код дальше, я наткнулся на отрицание дефайна __GNU_INLINE_X86_32__, который автоматически выставляется тогда, когда приложение собирается под архитектуру x86, но не под x86_64. Ниже за этим дефайном как раз и скрывался флажок, который говорил использовать таймеры SDL2, вместо получения частоты процессора с помощью различной магии вроде inline-ассемблера и инструкции rdtsc или чтения файла /proc/cpuinfo, поэтому я сделал так, чтобы этот флаг был активирован и для x86, что решило мою проблему.
Последний недочёт был связан с моей невнимательностью. Я пропустил в сборочном файле CMakeLists.txt установку флага -march=native, который буквально говорит компилятору: при генерации блоков машинного кода используй все навороченные и современные инструкции, которые доступны в процессоре твоего компьютера.
if(NOT PANDORA AND NOT HAIKU) message("Warning: arch-native will be used!") add_compile_options(-march=native) endif() if(HAIKU) if(CMAKE_SIZEOF_VOID_P EQUAL 4) # 32-bit message("Warning: Will building 32-bit executable with MMX, SSE, SSE2 support.") add_compile_options(-mmmx -msse -msse2) else() # 64-bit message("Warning: Will building 64-bit executable.") endif() endif()
Из-за этого пакеты в репозитории собрались эксклюзивно под мощнейший build-сервер и отказывались запускаться на компьютерах простых смертных людей, ругаясь на неправильные инструкции и опкоды. Отключение этого флага и ручное добавление поддержки инструкций MMX, SSE и SSE2 не только решило эту проблему, но и позволило скомпилировать огромную кучу inline-ассемблера в этом проекте, которая отвалилась после того, как был убран этот флаг.
К моему большому сожалению, разработчики Croteam не принимают какие-либо патчи в репозиторий движка, поэтому я сделал форк и выложил все свои наработки там:
https://github.com/EXLMOTODEV/Serious-Engine
Готовые к установке пакеты, позволяющие запустить игры серии Serious Sam, уже доступны в репозитории HaikuPorts. Только не забудьте скачать файлы данных игры.
Скажу честно, до недавнего времени я был совсем незнаком с этой игрой, которую в далёких 90-ых годах сделала отечественная студия разработчиков K-D Lab. Но участники конференции в Telegram IM, которая посвящёна обсуждению операционной системы Haiku, попросили меня портировать Вангеров и дали мне ссылку на GitHub-репозиторий, в котором находились исходники этой игры.
Стянув исходники в Haiku я попытался их скомпилировать и у меня это удалось без каких-либо особых проблем. Немного пришлось повозиться с отсутствием некоторых заголовочных файлов и с путями к библиотекам FFmpeg, которые используются движком этой игры. Я сразу начал подготавливать исходный код к пакетированию, поэтому добавил переменную окружения VANGERS_DATA и перенёс лог движка в пользовательскую директорию, доступную для записи.
Я запустил саму игру и спустя некоторое время по достоинству оценил всю ту атмосферу, которую удалось создать ребятам из K-D Lab. Через некоторое время я начал беспечно возить «Нимбус» в «Инкубатор» и «Флегму» в «Подиш», после чего мне даже удалось привезти «Элика» третьим. Вдоволь наигравшись, я начал подготавливать лаунчер для этой игры на основе своей библиотеки, про которую я написал выше.
Первая проблема, с которой я столкнулся, заключалась в том, что файлы данных игры, которые могли быть официально получены с помощью сервисов цифровой дистрибуции GOG.com и Steam, не хотели работать с движком. Мне пришлось связаться с человеком, который использует ник stalkerg и который занимался портированием Вангеров на Linux. Он рассказал мне какие именно файлы требуется подменить, чтобы всё запустилось и начало работать. Я последовал его рекомендациям и получил то, что мне требовалось.
Как и в случае с портом NXEngine (Cave Story), о котором я писал выше, русская и английская версия различаются между собой разными исполняемыми файлами, а вот директория с файлами данных у них общая, отличия имеются лишь в скриптах. По подсказке stalkerg я попробовал скомпилировать движок игры с опцией -DBINARY_SCRIPT=Off, которая активировала динамическую компиляцию этих скриптов во время исполнения, в том случае, если они имеются в каталоге файлов данных игры. Всё это позволило мне создать лаунчер, в котором имеется возможность переключения языка. Идея такая: предварительно проверяется директория игры, и если в ней нет необходимых скриптов, то они копируются из внутренностей пакета, после чего уже запускается исполняемый файл русской или английской версии.
При портировании Вангеров я задействовал одну интересную особенность, связанную с разделяемыми библиотеками, которая мне нравится в Haiku. Движок игры зависит от динамической библиотеки libclunk.so, которая отвечает за генерацию бинауральных звуков в реальном времени. И если в Linux, я должен ломать пальцы, подставляя в переменную окружения LD_LIBRARY_PATH путь до этой библиотеки, таким образом, чтобы и то что было в этой переменной до этого, тоже было сохранено, то в Haiku это сделано удобно, как и в Windows. Достаточно положить разделяемую библиотеку рядышком с исполняемым файлом и она будет подхвачена, с тем лишь отличием, что в случае с Haiku библиотеку необходимо положить в директорию ./lib/, что на мой взгляд позволяет сильно сэкономить время и нервы. Поэтому статическую компиляцию этой библиотеки я решил не рассматривать.
Разработчики Вангеров приняли мои изменения в движок своей игры, а готовые к установке пакеты доступны для скачивания из репозитория HaikuPorts или программы HaikuDepot, несмотря на недавний факап в инфраструктуре репозиториев, случившийся после обновления Linux-дистрибутива Fedora на новую версию.
При портировании движков Xash3D и Serious Engine, про которые я писал выше, я наткнулся в местном порте библиотеки SDL2 на полное отсутствие реализации диалогов. Диалоги вызываются двумя функциями SDL_ShowSimpleMessageBox() и SDL_ShowMessageBox(), которые позволяют проинформировать пользователя о какой-либо важной информации, например, об ошибке. Реализация этих диалогов доступна на многих платформах и операционных системах: Windows, macOS, iOS, X11 и Android, но почему-то отсутствует в Haiku. Я решил исправить это упущение и добавить эту функциональность в порт библиотеки SDL2.
В Haiku API, а точнее во фреймворке Interface Kit, имеется прекрасный класс BAlert, который отлично подходит для реализации подобных диалогов. Я решил выбрать его в качестве базового. Единственное, что меня смущало, это то, что я не был уверен в том, что в диалоге, который конструирует BAlert, можно разместить более трёх кнопок. Ещё я помнил про особенности управления памятью в этом классе, о которых я писал выше: его объекты можно создавать только в куче, и нельзя создавать на стеке, так как после вызова метода Go() и последующего действия пользователя он удаляет сам себя. Проведя некоторые эксперименты, я развеял все свои сомнения, унаследовался от этого класса и начал писать реализацию.
Первая трудность, с которой я столкнулся, состояла в том, что при использовании любого объекта класса BAlert или его наследников, необходимо было обязательно создать экземпляр системного класса BApplication, видимо чтобы зарегистрировать приложение в app_server для возможности взаимодействия с ним. Я создал экземпляр этого класса, но при вызове диалога BAlert из другого процесса или из созданного окна я получил другую ошибку, связанную с тем, что приложение не может иметь два объекта класса BApplication, к счастью я нашёл решение и этой проблемы. В Haiku API имеется глобальный указатель на текущий экземпляр класса BApplication, который называется be_app, его аналогом во фреймворке Qt является специальный макрос qApp, тоже определяющий указатель на текущий объект приложения. Так вот, достаточно просто проверять указатель be_app на NULL, и в том случае, если проверка завершилось успешно, создавать требуемый объект. Таким образом все эти проблемы были решены.
Стоит обязательно отметить то, что библиотека SDL2 написана на языке программирования C, а в Haiku API, как известно, используют язык программирования C++. Из-за этого некоторые части кода следует обязательно обмазать соглашениями о связывании
extern "C", чтобы не было никаких проблем с разрешением символов в процессе линковки. Кроме того, вместо new следует использовать оператор new(std::nothrow), чтобы иметь возможность проверять выделенную память по NULL, вместо выброса исключения, обработку которых SDL2, конечно же, не поддерживает.
В остальном ничего сложного не было. Пишем несколько функций, которые конвертируют сущности и представления SDL2 таким образом, чтобы они были совместимы с Haiku API и проверяем их корректную работу. Для различных проверок мной был расширен небольшой тест, который я периодически запускал на разных операционных системах, анализировал полученные результаты и оценивал свою работу. В конце-концов я так увлёкся, что сделал даже поддержку кастомизации, вроде задания разных цветов кнопкам и фону диалога. Это поддерживается в API библиотеки SDL2, но изначально я не планировал реализовывать такие вещи.
Если программист решит выплюнуть в этот диалог очень-очень длинную строку, то у объекта класса BTextView, который используется внутри объекта класса BAlert, требуется вызвать метод SetWordWrap() с аргументом true, чтобы ударить такого программиста по рукам и сделать так, чтобы диалог мог поместиться на экран. Казалось бы, нет ничего проще: проверяем длину строки с помощью функции strlen() и делаем нужное. Вот только проблема в том, что SDL2 работает так же и с UTF-8, а это значит, что функция strlen() будет возвращать количество байт, а не количество символов. На помощь приходит Haiku API и класс строк BString, в котором имеется метод CountChars(), позволяющий узнать длину строки в символах, а не в байтах:
bool CheckLongLines(const char *aMessage) { int final = 0; // This UTF-8 friendly. P.S. G_MAX_STRING_LENGTH = 120 BString message = aMessage; int32 length = message.CountChars(); for (int i = 0, c = 0; i < length; ++i) { c++; if (*(message.CharAt(i)) == '\n') { c = 0; } if (c > final) { final = c; } } return (final > G_MAX_STRING_LENGTH); }
Эта функция проверяет текст сообщения на строки длинной более 120-ти символов и если такие имеются возвращает истину. Насчёт UTF-8 обнаружился ещё такой момент, что в некоторых системных шрифтах Haiku отсутствует поддержка китайских иероглифов. Поэтому, к примеру, установить какую-нибудь китайскую надпись в заголовок окна, нельзя. А вот текст на русском языке устанавливается без проблем.
При подготовке пакета я столкнулся с ошибкой сборки под архитектуру x86_gcc2, которая активирована в рецепте библиотеки SDL2. Оказалось, что древнейший компилятор GCC 2.95 не может догадаться, что закомментированный код эквивалентен тому, что находится ниже:
rgb_color ConvertColorType(const SDL_MessageBoxColor *aColor) const { // return { aColor->r, aColor->g, aColor->b, 255 }; rgb_color color = { aColor->r, aColor->g, aColor->b, color.alpha = 255 }; return color; }
Поэтому мне пришлось переписать этот фрагмент в старом стиле и ещё убрать инициализацию некоторых констант в классе прямо в их объявлениях, это тоже не понравилось старому компилятору.
Я отправил патчи реализации диалогов SDL2 в репозиторий HaikuPorts, благодаря чему теперь движки Xash3D и Serious Engine могут корректно выдавать пользователю какую-либо информацию, например, об ошибках. А вот с разработчиками SDL2 я пока не связывался, но было бы прекрасно перенести все патчи из репозитория HaikuPorts в upstream библиотеки SDL2. Хотя работа по переносу наших патчей немного усложнилась из-за недавнего переименования префиксов функций с BE_* на HAIKU_*, но это не является такой уж серьёзной проблемой.
Я уже давно развиваю форк программы Cool Reader, которую написал Вадим Лопатин, соответствующая статья про это имеется на моём сайте. В комментариях к той статье постоянно отписываются читатели моего блога, которые либо хотят увидеть какую-нибудь новую возможность в своём любимом приложении для чтения электронных книг, либо хотят исправить ошибки и недочёты в уже реализованных функциях программы.
В репозитории HaikuPorts я обнаружил рецепт для сборки оригинальной программы Cool Reader, однако из-за каких-то постоянных изменений, происходящих с ресурсом SourceForge, этот рецепт оказался нерабочим, поскольку исходный код приложения стал недоступен для скачивания. Тогда я решил перенести свой форк в репозиторий HaikuPorts, в качестве новой версии программы Cool Reader. Я наложил все патчи Герасима на код, поправил некоторые недочёты в рецепте и на его основе создал новый пакет, который уже доступен всем пользователям Haiku. Исходный код моего форка программы Cool Reader вы сможете найти в этом GitHub-репозитории:
https://github.com/EXLMOTODEV/coolreader
Единственной проблемой, с которой я столкнулся, были неточности переноса патчей Герасима. Кроме дефайна __HAIKU__, где-то в системе сборки выставлялся ещё и дефайн _LINUX и, поскольку в большинстве случаев последний в листинге исходного кода стоял первым, условная компиляция подвела меня. В соответствии с правилами приоритета препроцессора, для Haiku компилировались именно те куски кода, которые были обрамлены дефайном _LINUX, хотя мне нужно было совсем другое. Но даже несмотря на это программа запускалась и работала, вот только сохраняла свои настройки не там, где это требовалось. Я правильно расставил приоритеты, пересобрал пакет и проблема полностью разрешилась.
В последнее время многие популярные операционные системы перешли на новое сочетание клавиш Meta/Opt/Cmd/Win+Space для переключения раскладки клавиатуры. Мне оно показалось очень удобным тем, что теперь не нужно ничего менять и настраивать. Садишься за любой компьютер под управлением macOS, Windows или Linux с оболочкой GNOME 3 и эта удобная комбинация смены языка ввода просто везде работает. Даже в мобильной операционной системе Android имеется её аналог. В общем, я давно полностью перешёл на эту клавиатурную комбинацию и сильно привык к ней.
К моему большому сожалению, программа KeymapSwitcher, которая поставляется с Haiku, не позволяла задать такое удобное сочетание клавиш для переключения раскладок, из-за чего я постоянно испытывал неудобство при работе с текстом в этой операционной системе. Поэтому я решил немного доработать это приложение и занялся поисками его исходного кода. Оказалось, что эта программа хоть и входит в дистрибутив Haiku, но поставляется отдельно от исходного кода самой операционной системы. Кроме того, приложение доступно в репозитории HaikuPorts и обновляется оно тоже через него. Как мне сообщили, KeymapSwitcher не включили в состав Haiku, потому что планируется реализовать специальное API для смены раскладок клавиатуры и когда-нибудь надобность в этой программе полностью отпадёт.
Несмотря на то, что меня пугали сложностью кода KeymapSwitcher, я довольно быстро нашёл нужное место благодаря комментариям и внедрил в код программы небольшой патч, который очень сильно облегчил мне набор каких-либо текстов в Haiku. Единственный небольшой недочёт, который я так и не смог побороть, заключается в том, что клавишу Opt требуется отпускать для переключения языка. То есть, зажать Opt и пробелом переключаться между выбранными языками не получится. Но это абсолютно никак не мешает переключению языков во время набора текста, поэтому я отправил патч в репозиторий программы и обновил пакет приложения в HaikuPorts, после чего новая версия KeymapSwitcher стала доступна для установки всем пользователям Haiku. Надеюсь, я не единственный пользователь этого сочетания клавиш для переключения раскладок клавиатуры.
Изучение Haiku API, а также разрешение различных экзотических проблем, возникших вследствие портирования новых и доработки существующих приложений для этой операционной системы, принесли мне огромное количество ценного опыта и удовольствия. Я смог продвинуть патчи поддержки Haiku в репозитории исходного кода некоторых крупных проектов и познакомился с новыми интересными людьми, так или иначе связанными с этой прекрасной операционной системой.
Я искренне надеюсь, что в будущем все сегодняшние проблемы вроде отсутствия аппаратного 3D-ускорения и популярных браузеров, а также слабой поддержки современного железа, будут успешно решены и Haiku получит приток новой крови разработчиков и пользователей, которые по достоинству оценят её уникальные возможности и самобытный дизайн. К счастью, разработка далеко не стоит на месте и уже сегодня на местном форуме этой операционной системы поднимаются горячие темы про 3D-ускорение и про портирование библиотеки GTK+3, а в репозиториях HaikuPorts обсуждается возможность переноса компонента QtWebEngine. Порт GTK+3 может повлечь за собой возможность запуска и работы популярных браузеров Firefox и Chromium, а QtWebEngine позволит использовать движок Blink в современных браузерах, основанных на фреймворке Qt, таких как Otter Browser или Falkon.
Уже сейчас я могу порекомендовать эту операционную систему тем, у кого имеются старые и слабые ноутбуки или нетбуки, например, вместо дистрибутива Lubuntu или Windows XP. Вы будете поражены тем, насколько быстро и отзывчиво она работает. Да, придётся немного ограничить себя в просмотре некоторых сайтов из-за старых браузеров и кучи глюков, которые связаны с ними, однако для большинства случаев на старом железе это ограничение не является сколько бы то ни было значимым.
Все мои порты и доработки уже опубликованы и доступны для установки всем пользователям Haiku. Все изменения исходного кода доступны в соответствующих репозиториях под их оригинальными лицензиями. В этой работе я использовал огромное количество материалов, основные из них я выделю в полезных ссылках ниже. Огромное спасибо ресурсам stackoverflow.com и google.com за то, что они есть.
Update 12-Nov-2019: Мои патчи реализации диалогов в библиотеке SDL2 для Haiku приняли в upstream проекта: 57ed423da32a и 007002587d5d. Подробную информацию по продвижению патчей можно посмотреть в системе отслеживания ошибок SDL2.
]]>История разработки инди-игры Gish берёт своё начало в 2003 году. Именно тогда независимая компания Chronic Logic, занимающаяся изданием и разработкой видеоигр, заключила контракт с художником и геймдизайнером Edmund’ом McMillen’ом, который впоследствии станет известным благодаря таким популярным инди-играм, как Super Meat Boy и The Binding of Isaac. Пока Edmund работал над графикой, программисты Alex Austin (под псевдонимом Cryptic Sea) и Josiah Pisciotta занимались написанием игрового движка. Разработка Gish была завершена весной 2004 года и уже в мае Chronic Logic выпустила игру на рынок США, где аркада получила восторженные и положительные отзывы критиков. Благодаря успеху игры, её дистрибуцией в 2004 году занялась такая компания, как Stardock, а в 2007 году Gish был добавлен в библиотеку игр в Steam’е. Кроме того, в 2010 году платформер был включен в первый сборник инди-игр под названием Humble Indie Bundle, который собрал более одного миллиона долларов. В знак признательности поддержавшему их сообществу, Cryptic Sea объявил о том, что исходный код игры Gish будет открыт и выпущен под лицензией GNU GPL v2.0, но ресурсы игры нужно будет приобретать отдельно.
1. Краткий обзор игры Gish
2. Подготовка исходного кода Gish к портированию на Android OS
3. Решение возникших проблем порта игры Gish на Android OS
4. Выбор рендерера: OpenGL ES и OpenGL (GL4ES)
5. Сенсорное управление и лаунчер игры
6. Заключение, полезные ссылки и ресурсы
Как уже было сказано выше, главным героем игры является небольшой шарик смолы по имени Gish, который может изменять своё физическое состояние. Сюжет аркады не слишком замысловат и повествуется в нескольких статичных иллюстрациях перед началом первого уровня игры. Gish и его подружка Brea прогуливались в парке, как вдруг из канализационного люка вылезло мерзкое чудовище, которое утащило девушку под землю. Бесстрашный Gish прыгает за ним в канализацию и отправляется на поиски своей возлюбленной, после чего, собственно, и начинается сама игра, конечной целью которой является спасение несчастной Brea.
Первый уровень платформера выполнен в виде обучающей локации, где игрока познакомят с основными клавишами управления и методами взаимодействия с игровым миром. Gish может становиться тяжёлым и твёрдым, чтобы давить своей массой врагов, ломать преграды и тонуть в воде. Для того, чтобы карабкаться по стенам и потолкам, наш герой может выдвигать специальные шипы на своей оболочке, которые, помимо этого, позволяют ему передвигать и тащить некоторые предметы. Ещё Gish умеет становиться скользким, что помогает ему протискиваться в различные узкие проходы и секретные места, часто встречающиеся на уровнях игры. Все эти умения можно комбинировать, что позволяет игроку с лёгкостью преодолевать некоторые трудные локации.
Кнопка прыжка создаёт некоторый резонанс в теле Gish’а, комбинируя это действие с нажатием клавиш «вниз» и «вверх», можно высвобождать накопленную энергию и тогда главный герой будет довольно высоко подпрыгивать.
Локации платформера наводнены различными головоломками, связанными с физикой самой игры. В некоторых местах нужно найти и положить ящик на кнопку открывающую двери, чтобы пройти уровень дальше. Иногда потребуется подбрасывать камни вверх, чтобы пробить потолок и высвободить тяжёлый груз, который проломит пол и откроет проход в новые локации.
Уровни разбиты на пять различных глав, в каждой из которых локации выполнены в какой-либо определённой стилистике. Gish побывает не только в мрачной и грязной канализации, но и в затопленных шахтах, в подземном Аду, в древнем Египте и даже в заброшенной церкви.
Многообразие локаций в Gish, на последнем изображении первый босс в игре, скриншоты с Motorola Photon Q (превью, увеличение по клику).
В конце каждой главы игрок столкнётся с достаточно сложными боссами, после прохождения которых он попадёт на забавный переходный уровень, сочетающий в себе сеттинг сразу двух глав: предыдущей и последующей. Помимо этого, на таких уровнях можно получить дополнительные жизни и набрать множество очков.
Игра требует хорошей сноровки и к её управлению необходимо привыкать. Например, некоторые места проходить значительно легче, если у Gish’а был сохранён импульс разбега. Я довольно долго не мог научиться высоко прыгать, в игре есть некоторые места, где высокие прыжки необходимы для дальнейшего прохождения. Платформер Gish наполнен множеством секретов, скрытыми уровнями и различными пасхалками, что, несомненно, будет приятным сюрпризом для большинства игроков.
Платформер от Cryptic Sea получился необыкновенной и оригинальной двумерной аркадой. Если вы никогда не играли в Gish, то обязательно ознакомьтесь с этой отличной игрой. В 2010 году я очень обрадовался тому, что исходники Gish стали доступными для изучения, поскольку всегда хотел поиграть в этот платформер на каком-нибудь смартфоне. Мобильная Java-версия игры, называемая Gish Reloaded и являющаяся сюжетным продолжением оригинала, была весьма обрезанной в плане графики и физики, а звуковое сопровождение и вовсе было куцым.
Играя в школьные годы в Gish Reloaded, я не ощущал ту мрачную атмосферу, которая присутствовала в компьютерной версии игры. Поэтому я захотел портировать Gish на свой MotoMAGX-смартфон, Motorola ZINE ZN5. Скачав исходный код, я понял, что из этой затеи абсолютно ничего не выйдет. Gish требовал OpenGL, а следовательно и GPU, который отсутствовал в моём телефоне. Программный рендеринг к такой игре написать было не в моих силах, поэтому я отложил эту идею до лучших времён и до более крутых смартфонов. Теперь я с уверенностью могу сказать, что эти времена наступили и я решил портировать компьютерную версию Gish на устройства под управлением Android OS.
Как было отмечено выше, игра использует для рендеринга старую версию библиотеки OpenGL 1.3, которая недоступна на Android OS. Программист Pickle в 2013 году написал для Gish рендерер, использующий библиотеку OpenGL ES, чтобы перенести эту игру на портативные игровые консоли Pandora и GCW Zero. Он назвал свой проект GishGLES и любезно выложил его исходный код на GitHub. Я решил форкнуться от репозитория Pickle и воспользоваться его наработками по рендереру, поскольку OpenGL ES сейчас доступен на всех Android-устройствах.
Традиционно, я начал портирование движка игры со сборки из исходного кода рабочей версии Gish под GNU/Linux. Проекту требовались следующие библиотеки: SDL, OpenAL, Ogg Vorbis, PNG и OpenGL. Установив все необходимые зависимости, я сразу же столкнулся с проблемами при компиляции. Дело было в том, что Gish использовал старую версию библиотеки PNG, а в новой сломали API и мне пришлось немного переписать функцию loadtexturepng(), чтобы исправить все ошибки сборки. Позже я понял, что эта функция используется в недоделанной свободной реализации Freegish, а оригинальная игра работает только с текстурами в формате TGA. Поэтому на будущее я избавил Android-проект от зависимости в виде библиотеки поддержки текстур формата PNG.
Полную версию Gish’а я покупал ещё давно, в первом Humble Indie Bundle, поэтому я просто развернул Data-файлы платформера рядом со скомпилированным исполнительным бинарником и игра запустилась. Теперь, когда у меня была рабочая версия Gish, я взялся за её перенос на SDL2, так как эта библиотека официально доступна для Android OS. Каких-либо проблем с портированием движка на SDL2 у меня не возникло, этот процесс я описывать не буду, так как в статье про портирование игры Ken’s Labyrinth на Android OS я уже его подробно разбирал. Единственное затруднение возникло с получением списка видеорежимов, но обратившись к документации SDL2 и структуре SDL_DisplayMode, я полностью решил и эту проблему. Цикл, добавляющий видеорежимы в список доступных в настройках игры, получился следующий:
for (i = 0; i < display_mode_count && display_mode_count < 64; ++i) { if (SDL_GetDisplayMode(display_in_use, i, &mode) != 0) { TO_DEBUG_LOG("SDL_GetDisplayMode failed: %s\n", SDL_GetError()); return; } f = mode.format; TO_DEBUG_LOG("Mode %i bpp %i %s %ix%i\n", i, SDL_BITSPERPIXEL(f), SDL_GetPixelFormatName(f), mode.w, mode.h); // if (SDL_VideoModeOK(sdlmode[count]->w,sdlmode[count]->h,32,SDL_OPENGL|SDL_FULLSCREEN)) { sdlvideomode[numofsdlvideomodes].resolutionx=mode.w; sdlvideomode[numofsdlvideomodes].resolutiony=mode.h; sdlvideomode[numofsdlvideomodes].bitsperpixel=32; numofsdlvideomodes++; // } // if (SDL_VideoModeOK(sdlmode[count]->w,sdlmode[count]->h,16,SDL_OPENGL|SDL_FULLSCREEN)) { sdlvideomode[numofsdlvideomodes].resolutionx=mode.w; sdlvideomode[numofsdlvideomodes].resolutiony=mode.h; sdlvideomode[numofsdlvideomodes].bitsperpixel=16; numofsdlvideomodes++; // } }
В библиотеке SDL2 на Android OS акселерометр умеет работать как джойстик, что довольно интересно и может быть полезным. Поэтому я решил оставить поддержку джойстиков в своём порте и вынести этот параметр в игровой лаунчер позже.
Обычные джойстики, которые можно подключить к Android-устройствам, работают без особых проблем. Перенос платформера Gish на новую библиотеку SDL2 по времени занял у меня всего один вечер.
Теперь, когда игра работала через SDL2, мне требовалось найти порты остальных нативных библиотек, отсутствующих в стандартной поставке Android OS. Движок Gish использовал для вывода звука библиотеки Ogg Vorbis и OpenAL. Зависимость от первой либы разрешалась достаточно легко, я просто перенёс в проект внутренние куски кода из библиотеки SDL2_mixer, которые отвечали за поддержку звукового формата Ogg Vorbis. Здесь мне тоже пришлось столкнуться с некоторыми проблемами несовместимости API старых и новых библиотек. Gish использовал новый Ogg Vorbis, а куски из распотрошённого SDL2_mixer почему работали со старым, кроме того, названия заголовочных файлов немного отличались. Единственная проблема крылась в функции ov_read(), в новой версии библиотеки она была снабжена дополнительными параметрами. Раскидав все отличия по дефайнам и написав сценарий сборки статической библиотеки, я полностью исправил ошибки компиляции и продолжил своё исследование.
Далее оставалось дело за библиотекой OpenAL, а точнее, за её свободной реализацией под именем OpenAL Soft. Кроме официального репозитория, в котором была обеспечена поддержка Android OS, я нашёл в интернете несколько других портов этой либы: openal-soft-android, openal-soft/android и openal-soft/android/lowlatency. Собрав несколько тестовых приложений, я абсолютно не заметил какой-либо разницы между ними, и, поэтому, выбрал вариант openal-soft/android, так как эта библиотека получилась самой компактной и её исходный код располагался в официальных репозиториях проекта OpenAL Soft. Единственное, меня смутило то, что репозиторий не обновлялся с 2012 года. Зато там был необходимый мне файл Android.mk, являющийся сборочным рецептом либы. Современный OpenAL использовал CMake даже для сборки на Android OS и мне пришлось бы писать Android.mk самому по сборочному логу, что было весьма неблагородным делом. Тем не менее, старая библиотека отлично работала и мало весила, что меня полностью устраивало. Кроме того, я активировал в OpenAL поддержку вывода звука через OpenSL ES, что, как мне показалось, немного снизило нагрузку на CPU. Библиотека OpenSL ES сразу доступна в стандартной поставке Android NDK и используется практически на всех Android-девайсах для работы со звуком.
К слову, я немного изменил своё окружение для разработки Android-приложений. Если раньше я использовал дистрибутив Arch Linux, KDE Plasma 5, Eclipse и ant, то теперь я перешёл на Fedora 25, GNOME 3, Android Studio и Gradle. Потихоньку привыкаю к новой обстановке, которая кажется мне достаточно удобной для работы. Единственное, чтобы скрыть крупные и неказистые заголовки окон у развёрнутых программ, я установил специальное дополнение Pixel Saver, которое доступно на официальном сайте расширений GNOME 3.
Компания Google в последнем обновлении Android SDK полностью сломала Android ADT и возможность создания приложений в Eclipse, поэтому мне пришлось переводить все свои предыдущие проекты на Android Studio. К сожалению, в Android Studio всё ещё достаточно слабая поддержка Android NDK, C, C++ и CMake, поэтому в качестве второй среды разработки у меня был постоянно запущен Qt Creator, в котором я вносил изменения в нативный код движка игры Gish.
Возможно, в будущем я полностью перейду на Android Studio, но пока мне нравится скорость работы Qt Creator и качество разбора кода парсером Clang.
Итак, на следующий день после начала работы над кодом, я смог собрать рабочий APK-пакет, который запускал игру Gish на моём Android-устройстве Motorola Photon Q. Конечно, я столкнулся с большой кучей проблем, которые позже постарался решить наиболее оптимальными способами. Но сам факт того, что игра заработала на моём смартфоне без значительных изменений, меня настолько обрадовал, что я забросил все дела и начал проходить эту игру на девайсе, используя физическую клавиатуру. Мне даже удалось полностью пройти две главы, чему я несказанно удивился.
Запустив игру на Android-смартфоне, я заметил, что в ней полностью отсутствовали звуковые эффекты, но при этом музыка работала без проблем. Внимательно изучив исходный код Gish, я увидел, что звуки в формате WAV и DAT загружаются с помощью библиотеки SDL2, посредством макроса SDL_LoadWAV(), но при этом SDL2 никак не участвует в их проигрывании, этим занимается OpenAL. Кроме того, я обнаружил, что в исходном коде очень часто используется функция chdir() для изменения директории с нужными Data-файлами игры. Отладка помогла мне выявить проблему отсутствия звука. Оказывается, этот макрос в Android-версии SDL2 не дружит с относительными путями, а только с абсолютными. Возможно, это как-то связанно с поддержкой assets’ов в функциях подмножества SDL_RWops. Мне пришлось написать небольшую функцию stringconcat(), конкатенирующую строки и передавать в макрос полный путь к звуковым файлам:
char* stringconcat(const char *s1, const char *s2) { char *result = malloc(strlen(s1)+strlen(s2)+1); strcpy(result, s1); strcat(result, s2); return result; } void setupaudio(void) { char *path = stringconcat(gishDataPath, "sound/blockbreak.wav"); loadwav(0,path); free(path); path = stringconcat(gishDataPath, "sound/rockhit.wav"); loadwav(1,path); free(path); ... }
Функция loadwav() содержит в своём теле макрос SDL_LoadWAV(), которому передаётся абсолютный путь. Это исправление помогло вернуть все звуковые эффекты обратно в игру.
Далее, я заметил, что стрелки на клавишах в первом обучающем уровне Gish никак не отображаются. Заглянув в исходный код игры, я удивился тому, что IDE запрещала редактировать мне файл gish/src/main/cpp/Gish/menu/menu.c, ссылаясь на невозможность определения его кодировки. В этом большом файле на добрую тысячу строк я с трудом обнаружил такое:
Вот почему использование в исходниках символов, отличных от входящих в таблицу ASCII, считается дурным тоном программирования. Кто-то когда-то пересохранил файл в какой-то программе и то, что там было раньше, просто потерялось. Открыв текстуры, отвечающие за шрифты и проведя небольшое расследование, я выяснил коды тех непечатных символов, которые должны там быть изначально. Не совсем понятно, что мешало разработчикам сразу написать вот так:
strcpy(keyboardlabel[SCAN_LEFT],"\x80"); strcpy(keyboardlabel[SCAN_RIGHT],"\x81"); strcpy(keyboardlabel[SCAN_UP],"\x83"); strcpy(keyboardlabel[SCAN_DOWN],"\x82");
И не знать проблем в дальнейшем. Поправив код, я скомпилировал движок под десктоп и удостоверился, что всё работает отлично. Позже скомпилировал Gish под Android и не поверил своим глазам: стрелки снова не отображались. Я потратил целых два часа на поиск проблемы, перебирая различные варианты, начиная с полной очистки директории сборки и заканчивая пошаговым выполнением некоторых функций. Баг упорно не давал себя обнаружить.
В конец отчаявшись, я начал в уме перебирать отличия архитектуры x86 от ARM и сразу же вспомнил про unsigned char, по умолчанию использующийся в ARM-компиляторах. Я выставил компилятору флажок -fsigned-char, пересобрал APK-пакет и стрелки появились. Но мне стало интересно, где находится корень этой проблемы и можно ли сделать так, чтобы не приходилось прибегать к использованию этого флага. Я когда-то где-то читал, что беззнаковый unsigned char на ARM’ах был сделан в угоду эффективности и производительности. Спустя некоторое время работы в отладчике я забрёл в огромную функцию drawtext() и нашёл там проблемное место. Я написал небольшую программку, демонстрирующую этот баг:
// 0x80.c #include <stdio.h> int main() { printf("Bug:\t%f\n", ('\x80' >> 4) * 16.0f + 0.5f); printf("No bug:\t%f\n", ((unsigned char) '\x80' >> 4) * 16.0f + 0.5f); return 0; }
А вот результат её выполнения при параметрах компилятора -fsigned-char (по умолчанию на x86) и -funsigned-char (по умолчанию на ARM):
gcc -fsigned-char 0x80.c # x86 default ./a.out Bug: -127.500000 No bug: 128.500000 gcc -funsigned-char 0x80.c # ARM default Bug: 128.500000 No bug: 128.500000
Как видно, проблема была в том, что символ рисовался, но одна его координата была рассчитана неверно, за пределами необходимых границ. Явное приведение символа к типу unsigned char помогло исправить этот двойной баг. Вот с такой интересной проблемой я столкнулся и успешно её решил.
Далее, на экране титров я обнаружил немного съехавший текст. И снова, эта проблема проявлялась только на Android-устройстве, а на компьютере было всё отлично. Погрузившись в код, я нашёл очень подозрительное место, которое я выделил в следующую программку, демонстрирующую баг:
// txt.c #include <stdio.h> #define TXT_GISHUSES "Gish uses" int main() { printf("Bug:\t%d\n", sizeof(TXT_GISHUSES+1)*12+66); printf("No Bug:\t%d\n", sizeof(TXT_GISHUSES)*12+66); return 0; }
Попробуем её скомпилировать для x86 и ARM, а затем запустить:
gcc txt.c ./a.out Bug: 162 No Bug: 186 arm-linux-androideabi-gcc -static txt.c qemu-arm ./a.out Bug: 114 No Bug: 186
Координаты в первом случае на разных архитектурах получились разными. Не знаю, что хотели сказать авторы кода подобной конструкцией в sizeof(), возможно они просто хотели сдвинуть надпись на один пиксель вправо, но ошиблись и вставили единичку не туда, куда нужно. Я исправил это небольшое недоразумение.
Далее, гуляя по меню игры, я столкнулся с ещё одним непонятным и неприятным моментом. Дело было в том, что мультиплеерные карты, рассчитанные на несколько игроков, просто не запускались. Оказывается, в форке Freegish некий FrozenCow зачем-то полностью сломал совместимость движка с оригинальной игрой Gish, изменив названия некоторых карт. GishGLES и, соответственно, мой порт унаследовали эту проблему от Freegish. Мне пришлось переписать код загрузки уровней, вернув туда оригинальное поведение.
if (!cache_fix) { switch (versusnum) { case 0: gametypeName = is4Player ? "4bath" : "bathhouse"; break; case 1: gametypeName = is4Player ? "4field" : "field"; break; case 2: gametypeName = "amber"; break; case 3: gametypeName = "fight"; break; case 4: gametypeName = "dragster"; break; case 5: gametypeName = "colvs"; break; case 6: gametypeName = "racing"; break; } } else { switch (versusnum) { case 0: gametypeName = is4Player ? "4sumo" : "2sumo"; break; case 1: gametypeName = is4Player ? "4football" : "2football"; break; case 2: gametypeName = "2greed"; break; case 3: gametypeName = "2duel"; break; case 4: gametypeName = "2dragster"; break; case 5: gametypeName = "2collection"; break; case 6: gametypeName = "2racing"; break; } } strcpy(filename, gametypeName); // 2 player. if (menuitem[1].active) if (cache_fix) strcat(filename, "1"); else ; else if (menuitem[2].active) strcat(filename, "2"); else if (menuitem[3].active) strcat(filename, "3"); else if (menuitem[4].active) strcat(filename, "4"); // 4 player. else if (menuitem[5].active) if (cache_fix) strcat(filename, "1"); else ; else if (menuitem[6].active) strcat(filename, "2"); strcat(filename, ".lvl");
Я решил выделить этот фикс в отдельный параметр «Fix cache», который отключен по умолчанию, а при активации возвращает названия карт от FrozenCow’а.
Исправив вышеописанные проблемы, я начал искать другие баги, но ничего критичного больше не смог найти. Подвергнув игру всестороннему тестированию в виде прохождения нескольких глав, я не обнаружил серьёзных неполадок и остался доволен результатом.
К сожалению, рендерер OpenGL ES, написанный программистом Pickle, имел весьма посредственную реализацию освещения, отличающуюся от оригинала. Помимо этого, в компьютерной версии Gish на главном герое игры просчитывались блики света и отражения, что было вырезано из упрощённого рендерера OpenGL ES. Мне захотелось вернуть в игру её оригинальную отрисовку, работающую на OpenGL. Но так как десктопный OpenGL недоступен на Android OS из коробки, мне пришлось искать проекты, которые занимались трансляцией вызовов OpenGL в OpenGL ES. Хорошенько обследовав GitHub, я нашёл два интересных проекта: nanogl от Olli Hinkka и GL4ES от ptitSeb, известного разработчика в тусовке обладателей игровой консоли и UMPC под названием Pandora.
Суть сводилась к следующему: я должен был отключить OpenGL ES рендерер в Gish, заменив его на оригинальный OpenGL’овский. Затем просто прилинковать статически наиболее подходящую библиотеку-транслятор. Свои эксперименты я начал с либы nanogl, но довольно быстро выяснилось, что необходимое мне расширение GL_ARB_texture_env_dot3 там отсутствовало. Проект с этой библиотекой даже не собирался и я переключился на GL4ES. Этот транслятор оказался просто отличным, он реализовывал все необходимые мне функции из множества OpenGL 1.3 и завёлся на Android OS без каких-либо серьёзных проблем. Пересобрав Gish вышеописанным образом я заметил, что освещение и блики стали такими, как в оригинале, что сильно меня порадовало.
Сравнение рендереров OpenGL ES и OpenGL (GL4ES), скриншоты с Motorola Photon Q (превью, увеличение по клику).
Но без нескольких ложек дёгтя, к сожалению, не обошлось. Во-первых, отрисовка через GL4ES была немного медленнее, чем через нативный OpenGL ES. Во-вторых, после нескольких тестов я обнаружил проблемы с отображениями 2D-текстур курсора и некоторых надписей. В-третьих, этот транслятор отказывался работать на моём старом смартфоне Motorola Droid 2. Третью проблему мне не удалось исправить и поэтому я решил в своём порте Gish реализовать выбор метода рендеринга. Для этого в лаунчере игры я сделал специальный переключатель, затем я поправил рецепт Android.mk для сборки нативного движка игры таким образом, чтобы из исходного кода компилировалось две библиотеки: libGish.so и libGishGLES.so, отличающиеся лишь набором дефайнов и прилинкованных либ. Первая библиотека будет загружена, если в лаунчере выбран транслятор GL4ES, соответственно, вторая библиотека будет использована при выборе нативного OpenGL ES. В Java-обёртке SDL2, которая загружает нативные библиотеки приложения, это выглядит следующим образом:
protected String[] getLibraries() { return new String[] { "SDL2", "OpenAL", // "SDL2_image", // "SDL2_mixer", // "SDL2_net", // "SDL2_ttf", GishSettings.openGles ? "GishGLES" : "Gish" }; } // Load the .so public void loadLibraries() { for (String lib : getLibraries()) { System.loadLibrary(lib); } }
Статическая переменная GishSettings.openGles связана с лаунчером игры и меняется в зависимости от выбора пользователя. Функция getLibraries() возвращает массив названий динамических библиотек. Две библиотеки движка Gish’а отличаются между собой лишь тем, что libGishGLES.so собрана с дефайном -DGLES, а libGish.so с -DGL4ES и ещё к этой либе статически линкуется транслятор GL4ES.
Баг, связанный с проблемой отображения некоторых 2D-текстур, мне удалось полностью пофиксить с помощью расстановки OpenGL-функций glPushMatrix() и glPopMatrix() перед вызовами glBegin() и после вызовов glEnd() соответственно. Я не являюсь специалистом в области компьютерной графики и OpenGL и поэтому не уверен, что это самое верное и оптимальное решение. Но эта проблема, после внесения моих изменений в код, волшебным образом исчезла.
Для того, чтобы повысить FPS в игре, я решил добавить в лаунчер Gish’а несколько параметров. Первые два из них позволяют отключать освещение и тени, а третий задаёт размер проекции (viewport) игрового экрана. Проведя несколько экспериментов, я убедился в том, что отключение освещения и теней повышает FPS, но не слишком значительно, а вот уменьшение параметра «zoom», то есть приближение картинки к игроку, действительно положительно сказывается на количестве кадров в секунду. Хотя, конечно, слишком маленький параметр «zoom» сильно сокращает угол обзора уровня и создаёт много неудобств, но подобрать оптимальное значение всё-таки можно.
Сравнение уровней с отключенным и включенным освещением и тенями, рендерер OpenGL (GL4ES), zoom 8.0, скриншоты с Motorola Photon Q (превью, увеличение по клику).
При отключенном освещении у меня возник один забавный баг: босс-приведение в конце второй главы не умирал после нажатия на рычаг и игру стало невозможно пройти дальше. Дело было в том, что в коде логики игровых боссов у этого приведения была прописана смерть от яркого источника света, который активировался при нажатии на рычаг.
// gish/src/main/cpp/Gish/game/boss.c // void bosssimulation(void) if (frame.numoflights>1) { if (boss[count].timetolive>150) { boss[count].timetolive=150; } }
Когда я полностью выключил освещение в игре, этот уровень во-первых, стал очень тёмным, а во-вторых, логика, завязанная на источниках света, перестала работать. Поэтому мне пришлось вносить несколько фиксов в игру специально для этого уровня:
// gish/src/main/cpp/Gish/game/game.c // void gameloop(void) if (lighting_enabled) { setupframelighting(); } else if (game.levelnum == 13) { // cave6.lvl boss setupframelighting_fix_cave6_boss(); } // gish/src/main/cpp/Gish/game/level.c // void loadlevel(char *filename) // EXL: Patch for lights off if (!lighting_enabled) { if ((strcmp(filename, "cave6.lvl")==0) || (strcmp(filename, "twilight5.lvl")==0)) { float ambient[4][3] = { { 0.150000, 0.250000, 0.150000}, { 0.600000, 0.700000, 0.600000}, { 0.600000, 0.700000, 0.600000}, { 0.200000, 0.600000, 0.200000} }; memcpy(level.ambient, ambient, sizeof(ambient)); } } // gish/src/main/cpp/Gish/game/lighting.c void setupframelighting_fix_cave6_boss(void) { int count; float vec[3]; frame.numoflights=0; for (count=0; count < numofobjects; count++) { if (frame.numoflights < 8) { if (object[count].lighton) { if (object[count].lighttype>=1 && object[count].lighttype<=3) { if (vectorlength(vec) < view.zoom+2.0f+object[count].lightintensity * 0.5f) { frame.numoflights++; } } } } } }
Для исправления проблемы с боссом мне пришлось даже написать специальную функцию setupframelighting_fix_cave6_boss(), которая вызывается только на уровне с приведением и повышает количество источников света в нужный момент. Такой вот интересный баг.
Отключенное освещение не слишком сильно влияет на атмосферу и прохождение игры. Кстати, хотел бы выразить огромную благодарность человеку с ником SysLord. Я интегрировал в свой порт несколько интересных возможностей, которые он добавил в репозиторий форка Gish’а на GitHub’е, например, отображение FPS.
Как и в своих прошлых проектах, в порте Gish на Android OS я сделал возможность выбора нескольких вариантов сенсорного управления. Реализация первого типа управления является наиболее простой и использует прозрачную подложку с изображениями навигационных кнопок, которая элементарно накладывается поверх отрисованной картинки.
Далее отлавливаются нажатия на импровизированные сенсорные кнопки и, в зависимости от попаданий в эти прямоугольные области, в движок игры отправляются различные события. Методы создания такого варианта управления я подробно описывал в статье, посвящённой портированию Ken’s Labyrinth на Android OS.
Второй тип управления был придуман программистом, использующим никнейм [SoD]Thor. Его отличительной особенностью является удобный сенсорный джойстик и индикация нажатий на кнопки.
Здесь задействован приблизительно такой же механизм работы, как и в первом варианте управления. Только вместо статичного изображения на игровой экран накладывается специально подготовленный объект класса GishTouchControlsView, который, в свою очередь, является наследником системного класса View и занимается отрисовкой всех графических элементов и обработкой прикосновений к дисплею. Я модифицировал реализацию [SoD]Thor’а под свои нужды и интегрировал такой тип сенсорного управления в несколько своих портов различных игр. Более подробную информацию о моих изменениях, интеграции и принципах взаимодействия управления вы можете посмотреть в статье про портирование игры Adamant Armor Affection Adventure на Android OS.
Попробовав пройти несколько глав в Gish’е с помощью сенсорного управления, я удивился тому, каким удобным и отзывчивым получился второй вариант с наэкранным джойстиком. Даже на физической клавиатуре проходить игру мне оказалось несколько сложнее. А вот первый вариант с подложкой, для Gish’а совсем не подошёл и вышел неудобным. К недостаткам обоих типов управления можно отнести небольшую просадку FPS на старых устройствах. Ещё обнаружился довольно неприятный баг: из-за того, что экран настроек в игре управляется только курсором, там невозможно поменять какую-либо опцию. Я решил вместо этого экрана выводить предупреждение, которое говорит о том, что для изменения опций игры требуется отключить сенсорное управление.
По умолчанию, в библиотеке SDL2 тачскрин Android-устройства действует так же, как тачпад в ноутбуке, поэтому в работе курсора нет особых проблем. Все настройки игры я вынес в специальный игровой лаунчер, который украсил небольшим изображением-обложкой. Параметры сохраняются после выхода из приложения, что достаточно удобно и практично. Для быстрого сброса прогресса и всех опций игры и для удаления игровых профилей и конфигурации, я добавил в лаунчер Gish’а специальную кнопку «Reset Game…».
После нажатия на кнопку «Run Gish!» с формы лаунчера собираются все настройки и передаются в специальный класс GishSettings, который является статическим. Далее, запускается сам движок игры и с помощью технологии JNI получает необходимые параметры из этого класса и обновляет игровую конфигурацию. Более подробно создание подобных лаунчеров описано в моей статье, посвящённой портированию игры Spout на Android OS.
Поскольку в Android OS нет стандартного файлового диалога, то для выбора директории, в которой расположены Data-файлы игры, я использую специальный класс GishFilePickerActivity, реализацию которого я подсмотрел у программиста, использующего ник Solexid.
Этот диалог позволяет выбрать любой каталог в файловой системе Android-устройства и пробросить его с помощью JNI в нативный движок Gish’а. Получить дополнительную информацию об этом механизме можно в моей статье про портирование игры Adamant Armor Affection Adventure на Android OS.
Движок Gish’а использует несколько специальных файлов для сохранения профиля игрока, его прогресса и конфигурации. В начале своего портирования я расположил эти файлы в директорию с кешем игры, но на последних версиях Android 6 и Android 7 я столкнулся с некоторыми проблемами записи данных на карту памяти и на другие внешние накопители. Поэтому было решено перенести эти файлы во внутреннее хранилище приложения, что избавило меня от подобных неприятностей. В SDL2 есть специальная функция SDL_AndroidGetInternalStoragePath(), которая при вызове возвращает путь до внутреннего хранилища игры.
В Gish’е из некоторых пунктов меню можно открывать ссылки на полезные интернет-ресурсы. Чтобы перенести эту функциональность в порт на Android OS, пришлось использовать технологию JNI.
void openUrlFromJNI(const char *url) { if (javaEnviron != NULL) { jclass clazz = (*javaEnviron)->FindClass(javaEnviron, "ru/exlmoto/gish/GishActivity"); if (clazz == 0) { TO_DEBUG_LOG("Error JNI: Class ru/exlmoto/gish/GishActivity not found!"); return; } jmethodID methodId = (*javaEnviron)->GetStaticMethodID(javaEnviron, clazz, "openUrl", "(Ljava/lang/String;)V"); if (methodId == 0) { TO_DEBUG_LOG("Error JNI: methodId is 0, method openUrl (Ljava/lang/String;)V not found!"); return; } jstring jurl = (*javaEnviron)->NewStringUTF(javaEnviron, url); // Call Java-method (*javaEnviron)->CallStaticVoidMethod(javaEnviron, clazz, methodId, jurl); // Delete Refs (*javaEnviron)->DeleteLocalRef(javaEnviron, jurl); (*javaEnviron)->DeleteLocalRef(javaEnviron, clazz); } }
Функция openUrlFromJNI() вызывается из нативного движка игры и вызывает из Java статический метод openUrl(), который находится в классе GishActivity и принимает String в качестве аргумента.
// JNI-function public static void openUrl(String aUrl) { Uri uriUrl = Uri.parse(aUrl); Intent intent = new Intent(Intent.ACTION_VIEW, uriUrl); intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); m_GishActivity.startActivity(intent); }
Объект m_GishActivity является просто статической ссылкой на текущий экземпляр класса, то есть this. При вызове этого метода управление передаётся в браузер, в котором и открывается необходимая URL-ссылка. Если браузеров несколько, то пользователю предлагается их выбор.
Таким образом, сенсорное управление, опции игры и её лаунчер реализованы в Java-обвязке приложения, тогда как сам движок и его вспомогательные зависимости подгружаются в качестве нативных динамических библиотек.
Портирование игры Gish на Android OS дало мне огромное количество ценного опыта и удовольствия. В первую очередь стоит отметить то, что я научился работать в новой среде GNOME 3 и в Android Studio. Я разобрался в том, как собирать с помощью Gradle приложения, использующие нативные библиотеки, Android NDK и JNI. Я познакомился с такими интересными либами, как OpenAL и GL4ES. Кроме того, я смог решить множество интересных проблем и интегрировать в проект современный и достаточно удобный вариант сенсорного управления и файловый диалог для выбора каталога с Data-файлами игры.
Размер установочного APK-пакета получился весьма небольшим, всего лишь 2.7 МБ для двух архитектур armeabi-v7a и x86. От armeabi пришлось отказаться, поскольку устройства на ARMv6 достаточно старые и слабые, а игра довольно требовательна к ресурсам смартфона. Минимальные системные требования: Android OS 2.3.3 и ARMv7- или x86-совместимый процессор.
Скачать APK-пакет с игрой Gish, готовый для установки и запуска на любом Android-устройстве, можно по этой ссылке:
[Скачать | Download] — APK-пакет Gish, v1.0, armeabi-v7a, x86, 2.7 МБ.
Приобрести официально игру Gish и получить Data-файлы, подходящие для запуска игры на Android OS, можно по этим ссылкам:
Купить игру Gish в Steam
Купить игру Gish на сайте Chronic Logic
Я проверил следующие версии игры и Data-файлов: Gish 1.53 (non-steam, Linux), Gish 1.6 (non-steam, MS Windows) и Gish 1.7 (steam version). Все они без каких-либо проблем работали с моим портом. Демоверсия Gish’а, к сожалению, работать не будет.
Дополнительно APK-пакет можно получить со следующих зеркал: Yandex.Disk и GitHub Releases.
Если игра на вашем девайсе идёт несколько медленно, то вот небольшие советы по увеличению FPS. Во-первых, необходимо отключить освещение «GFX Lights» и тени «GFX Shadows». Во-вторых, нужно выставить зум на 6.0 или 7.0, это значительно повысит FPS за счёт сокращения размера отображаемой сцены. В-третьих, следует переключиться на рендерер OpenGL ES, как на самый оптимальный. Если это не помогает, попробуйте ещё отключить музыку или все звуковые эффекты в игре.
Все исходные коды и проект в целом выложен в репозиторий на ресурсе GitHub. Мои изменения и исходные файлы доступны под лицензиями MIT и GNU GPL v2.0, исходный код игры Gish от Cryptic Sea доступен под лицензией GNU GPL v2.0, Data-файлы игры не являются свободными и их необходимо приобрести отдельно. Ссылка на репозиторий:
Проект можно открыть для изучения в Android Studio или собрать под десктопный GNU/Linux с помощью CMake. В этой работе я использовал огромное количество материалов, основные из них я выделю в полезных ссылках ниже. Огромное спасибо ресурсам stackoverflow.com и google.com за то, что они есть.
Update 31-JUL-2017: Спустя два месяца после выхода первой версии порта игры Gish для Android OS, я поднакопил немного исправлений и решил оформить новый релиз v1.1 приложения. Во-первых, мне удалось запустить игру на Android OS < 4.0 с рендерером GL4ES. Ранее при выборе этого параметра игра просто зависала сразу после запуска. Так происходило из-за того, что функция dlopen() в старых версиях Android OS не является рекурсивной. Более подробно о решении этой проблемы можно прочитать в этом issue репозитория GL4ES на GitHub’е.
Во-вторых, меня попросили реализовать возможность отключения рамки в самой игре. Я сделал дополнительную опцию в лаунчере для этого, а позже убрал небольшие артефакты рисования границ уровня путём увеличения размера viewport’а. Полный список изменений включает в себя следующее:
Update 01-MAR-2018: Подготовлен новый релиз v1.2 порта Gish на Android OS, который включает в себя некоторые небольшие исправления, основным из которых является переход на новый компилятор нативного кода Clang, благодаря ему размер APK-пакета уменьшился на 200 КБ. Список изменений:
Скачать готовый к установке APK-пакет последней версии порта Gish можно со странички проектов или из раздела релизов на GitHub’е.
]]>В далёком 2011 году энтузиастами было организовано мероприятие RIOT Tag-Team Coding Competition, целью которого было увеличение количества Homebrew-игр на различных карманных игровых устройствах на базе ядра Linux, в том числе и на девайсах от компании GPH, которая, кстати, была основным спонсором конкурса. Одной отличительной особенностью этого мероприятия являлось то, что Homebrew-игру необходимо было разрабатывать командой, а игры от «одиночек» на конкурс не принимались. Именно поэтому двое российских разработчиков «старой школы»: Don Miguel и quasist решили объединить свои усилия и начали работать над эксклюзивным игровым проектом для актуальных на тот момент времени консолей от GPH: GP2X Wiz и GP2X Caanoo. В GPH-тусовке это были достаточно известные личности, поскольку они и раньше разрабатывали игры на родственные платформы. В 2002 году Don Miguel сделал популярную игру Super Plusha для старой и самой первой портативной консоли от GPH: GP32. Разработчик quasist был известен несколькими интересными играми под GP2X, такими как FleshChasmer: The Eve, Worship Vector и Adamant Armor Affection.
Именно Adamant Armor Affection (или сокращённо AAA) и послужил прообразом игры, представленной на конкурс RIOT Tag-Team Coding Competition. В его название было добавлено ещё одно слово и сиквел стал называться Adamant Armor Affection Adventure (или сокращённо AAAA). Круто поменялась и основная концепция: вместо 2D-платформера был представлен 3D-action в стиле Minecraft с элементами Stealth-прохождения. За три месяца ребятам удалось сделать практически невозможное: разработать достаточно производительный и отлаженный 3D-движок, создать десяток разнообразных карт и монстров, сделать несколько режимов игры, собрать всё это воедино и достойно выступить на упомянутом выше мероприятии, заняв почётное второе место. Игра AAAA максимально использовала аппаратные возможности консоли GP2X Caanoo, такие как сенсорный экран, акселерометр и виброотдача. Немного позже авторы выпустили эту игру на десктопные платформы, а разработчик, использующий ник sebt3, портировал AAAA на гибрид UMPC и игровой консоли для гиков — Pandora. В 2016 году разработчик munchluxe63 портировал эту игру на ещё одну Linux-консоль: GCW Zero.
Вдохновившись как самой игрой, так и успехом и самоотверженным трудом её авторов, я решил «воздать славу» старой GPH-тусовке и портировать эту игру на Android OS. Печально, но именно эта операционная система, точнее дешёвые китайские игровые консоли на ней (например, от фирмы JXD) и загнали последний гвоздь в крышку гроба компании GPH, которая давно уже обанкротилась и закрылась. После покупки устройства на MotoMAGX OS, я начал активно следить за событиями сообщества владельцев портативных игровых консолей. Ведь множество их наработок можно было перенести и на мой девайс, Motorola ZN5. Увы, в этом устройстве отсутствовал графический ускоритель, а потому игра AAAA, рендеринг которой был завязан на библиотеку OpenGL ES, была для меня недоступным «запретным плодом», в который очень хотелось поиграть на каком-нибудь устройстве. К счастью, сейчас в каждом Android-девайсе есть GPU, поэтому я решил осуществить свою давнюю мечту, перенеся Adamant Armor Affection Adventure на Android OS.
1. Краткий обзор игры Adamant Armor Affection Adventure
2. Первые шаги: подготовка игры для переноса на Android OS
3. Первая кровь: получение работоспособной версии игры на Android OS
4. Вариации сенсорного управления
5. Работа с кешем игровых данных в OBB-файле
6. Лаунчер игры, дополнительные улучшения и инструменты
7. Инструмент для распаковки текстур
8. Заключение, полезные ссылки и ресурсы
К сожалению, в сжатые сроки конкурса авторам не удалось тщательно проработать игру, поэтому в ней присутствуют несколько серьёзных проблем, таких как: отсутствие вменяемого сюжета, запутанные уровни, слишком активный респаун врагов и некоторые сложности с игровым балансом. Тем не менее, технически игра выполнена очень качественно, это одна из самых сильных в графическом плане Homebrew-игр на Caanoo и GP2X Wiz. Сюжет игры не сильно замысловат и излагается синтезированным женским голосом и в текстовом виде во вступительном видеоролике, который можно пропустить. После его окончания открывается доступ к главному меню, в котором можно выбрать самую первую миссию, посвящённую обучению игровой механике.
Тренировочный уровень выполнен в импровизированном стиле «киберпространства» и снабжён различными голосовыми и текстовыми инструкциями, которые рассказывают о возможностях управления и различных особенностях геймплея. Именно на этой локации располагается специальный диск с восемью основными игровыми миссиями, его обязательно нужно подобрать.
Основные игровые уровни поражают своей разнообразностью. Главному герою придётся побывать в тёмных пещерах, вскарабкаться на гору, перебежать через населённую неведомыми существами пустошь, добраться до заброшенного замка и проникнуть в хорошо охраняемую секретную лабораторию. Игровой процесс достаточно увлекателен, кроме постоянного уничтожения врагов, приходится прыгать и по различным платформам. Очень понравилась возможность применения различной тактики прохождения. Действительно, лучше всего проходить AAAA в Stealth-режиме, аккуратно обходя и обманывая врагов. Кое-где необходимо пожертвовать своим персонажем, чтобы отвлечь монстров, преграждающих дальнейший путь.
Разнообразие основных уровней в игре, скриншоты с Motorola Photon Q (превью, увеличение по клику).
Из оружия у игрока имеется меч, очень полезный в ближнем бою, и ракетница, способная уничтожить одним метким попаданием большинство представленных в игре врагов. Количество боеприпасов неограниченно. Для прицеливания на дальние дистанции можно использовать специальные лазерные целеуказатели. Для выполнения более точных прыжков на платформы имеется переключение в режим игры от первого лица. Кроме того, удобный ракурс камеры можно получить с помощью акселерометра. Помимо этого, у главного героя имеется бесконечный фонарик, активация которого действительно помогает в некоторых тёмных локациях.
Как уже было сказано выше, предпочтительный метод прохождения игры, не идти напролом, а умело использовать Stealth-тактику. Примечательно, что главный герой может произносить звуки, чтобы отвлечь врагов и расчистить территорию.
На нескольких различных уровнях игры спрятаны специальные секретные диски и дискеты. До них нужно добраться и коснуться их, после чего в главном меню откроются ранее закрытые пункты с мини-играми, которые по задумке авторов должны просто снимать напряжение. К сожалению, они не имеют в себе никакого соревновательного элемента и не слишком интересны.
Секретные мини-игры и миссии, скриншоты с Motorola Photon Q (превью, увеличение по клику).
В подобных мини-играх реализован простенький клон Creative-режима игры Minecraft, бесцельная Stealth-игра от первого лица в фэнтезийной вселенной, режим бесконечного отстрела зомби и экстремальный уровень, пройдя который можно получить специальные секретные коды.
Ввод этих кодов на экране главного меню позволяет изменить дефолтный скин игрока, открыть все заблокированные миссии или запустить специальный режим редактора, в котором можно создавать собственные уровни и редактировать уже существующие. Очень интересная задумка в плане кастомизации, игроки даже опубликовали несколько собственноручно сделанных карт.
Встроенный в игру редактор уровней, скриншоты с Motorola Photon Q (превью, увеличение по клику).
Отдельное внимание стоит обратить на богатое звуковое сопровождение. Все музыкальные композиции были записаны лично quasist’ом и они действительно интересные и приятные на слух. Несмотря на все недоработки, эта игра всё равно чем-то цепляет и в неё действительно хочется играть снова, после её прохождения. Например, найти все секретные диски или получить секретные коды.
Исходный код игры AAAA достался мне в ужасном состоянии. Из него по какому-то странному стечению обстоятельств исчезли абсолютно все переносы. В сложных ветвлениях, например, относящихся к расчёту коллизий, всё было смешано в страшную кучу. Проблема ещё была в том, что скобки у выражений были расставлены не во всех случаях.
Я решил исправить эту плачевную ситуацию и взялся за инструменты, которые были специально предназначены для форматирования кода и приведения его к общему стилю. Попробовав самые простые, такие как Artistic Style (astyle) и Uncrustify, я остался неудовлетворён результатом их работы. Например, astyle добавлял скобки только в последнее выражение, а не во все нужные мне. Поэтому пришлось взяться за более тяжёлую артиллерию и установить инструменты из набора clang: clang-format и clang-tidy.
Их поочерёдное использование помогло достичь необходимого мне результата и код был отформатирован должным образом. Стоит отметить, что результат работы clang-tidy зависит от определённых дефайнов, поэтому необходимо пробежаться по всем доступным в коде определениям:
clang-tidy *.h *.c -fix -fix-errors -checks="readability-braces-around-statements" -- -I. clang-tidy *.h *.c -fix -fix-errors -checks="readability-braces-around-statements" -- -I. -DPC32 clang-tidy *.h *.c -fix -fix-errors -checks="readability-braces-around-statements" -- -I. -DGP2XWIZ clang-tidy *.h *.c -fix -fix-errors -checks="readability-braces-around-statements" -- -I. -DANDROID_NDK clang-tidy *.h *.c -fix -fix-errors -checks="readability-braces-around-statements" -- -I. -DGP2XCAANOO clang-tidy *.h *.c -fix -fix-errors -checks="readability-braces-around-statements" -- -I. -DANDROID_NDK clang-tidy *.h *.c -fix -fix-errors -checks="readability-braces-around-statements" -- -I. -DGP2X clang-tidy *.h *.c -fix -fix-errors -checks="readability-braces-around-statements" -- -I. -DPC_GLES clang-tidy *.h *.c -fix -fix-errors -checks="readability-braces-around-statements" -- -I. -DSDL2_PORT clang-format -style=WebKit -i *.h *.c
Напоследок я прошёлся по коду вышеупомянутой утилитой astyle, что сгладило все оставшиеся шероховатости. Продолжительное ручное тестирование показало, что логика работы кода после форматирования не была нарушена.
После успешной компиляции AAAA и проверке работоспособности игры на компьютере, я начал портировать движок с библиотек OpenGL, SDL и SDL_mixer на OpenGL ES, SDL2 и SDL2_mixer. SDL2-библиотеки уже давно доступны под Android OS и достаточно хорошо там работают. Более подробно портирование проекта с SDL на SDL2 я описал в своей предыдущей статье про Ken’s Labyrinth, поэтому не буду углубляться в эти аспекты. API библиотеки SDL2_mixer достаточно хорошо совместимо с SDL_mixer, поэтому здесь всё просто обошлось выставлением необходимой библиотеки для линковщика. А вот сделать десктопное приложение, использующее не OpenGL, а OpenGL ES было интересно.
Поскольку игра AAAA создавалась для GP2X Wiz и Caanoo, то её движок изначально использовал OpenGL ES для рендеринга, а OpenGL использовался только для десктопной версии. В своём интервью французскому форуму www.open-consoles.com, quasist рассказывал о том, что технически было сложно сделать универсальную систему рендеринга, поддерживающую как OpenGL, так и OpenGL ES. Это было крайне необходимо для продуктивной разработки игры, поскольку второй разработчик, Don Miguel, не имел подходящего железа для тестирования наработок и использовал компьютер. Так вот, все эти трудности с универсальным рендерером сегодня сошли бы на нет, поскольку на десктопных операционных системах имеются такие технологии как Mesa 3D и ANGLE, содержащие необходимые библиотеки и трансляторы. Поскольку я использую на своём ноутбуке GNU/Linux, то для этого эксперимента были выбраны библиотеки OpenGL ES, идущие в составе Mesa 3D. Код инициализации графической подсистемы получился следующий:
#ifdef PC_GLES EGLint egl_config_attr[] = { EGL_BUFFER_SIZE, 16, EGL_DEPTH_SIZE, 16, EGL_STENCIL_SIZE, 0, EGL_SURFACE_TYPE, EGL_WINDOW_BIT, EGL_NONE }; EGLint numConfigs, majorVersion, minorVersion; globalWindow = SDL_CreateWindow("Adamant Armor Affection Adventure", 0, 0, screenwidth, screenheight, (0) ? (SDL_WINDOW_OPENGL | SDL_WINDOW_FULLSCREEN) : SDL_WINDOW_OPENGL); glDisplay = eglGetDisplay(EGL_DEFAULT_DISPLAY); eglInitialize(glDisplay, &majorVersion, &minorVersion); eglChooseConfig(glDisplay, egl_config_attr, &glConfig, 1, &numConfigs); SDL_SysWMinfo sysInfo; SDL_VERSION(&sysInfo.version); // Set SDL version SDL_GetWindowWMInfo(globalWindow, &sysInfo); glContext = eglCreateContext(glDisplay, glConfig, EGL_NO_CONTEXT, NULL); glSurface = eglCreateWindowSurface(glDisplay, glConfig, (EGLNativeWindowType) sysInfo.info.x11.window, 0); eglMakeCurrent(glDisplay, glSurface, glSurface, glContext); eglSwapInterval(glDisplay, 1); // VSYNC glVertexPointer(3, GL_FIXED, 0, mesh); glTexCoordPointer(2, GL_FIXED, 0, mesht); glFogf(GL_FOG_MODE, GL_LINEAR); glAlphaFuncx(GL_GREATER, 65536 / 2); #endif
Библиотека SDL2 позволяет получить указатель на окно через обращение к полям структуры SDL_SysWMinfo. Этот указатель можно отправить в функцию eglCreateWindowSurface(), которая предоставляется библиотекой EGL, являющейся специальной обвязкой, реализующей интерфейс между оконной системой OS и необходимым графическим API. После вызова нужных EGL-функций, рендеринг осуществляется в необходимое мне окно. Роль SDL2-библиотеки сводится к минимальному — к созданию подходящего окна и к обработке поступающих в него событий. Примечательно, что обычная инициализация контекста OpenGL ES средствами SDL2 не работает должным образом на десктопе, хотя без проблем работает на Android OS. В сборочной системе я оставил возможность выбора между разными API для рендеринга:
gles { DEFINES += PC_GLES SDL2_PORT LIBS += -lSDL2 -lSDL2_mixer LIBS += -lGLESv1_CM -lEGL } else { DEFINES += PC_GL SDL2_PORT LIBS += -lSDL2 -lSDL2_mixer LIBS += -lGL }
Таким образом, на уровне сборки из исходного кода я могу собрать исполнительный файл игры как с OpenGL, так и с OpenGL ES. Вся описанная выше работа значительно облегчила перенос движка игры AAAA на Android OS.
Теперь, когда движок использовал кросс-платформенную библиотеку SDL2, его портирование на Android OS не должно было вызывать каких-либо сложностей. Подготовка проекта для сборки финального APK-приложения очень подробна расписана в файле README-android.md, который входит в комплект документации к библиотеке SDL2. Следуя изложенным там советам, я создал, настроил, собрал проект и получил готовый к установке APK-пакет. Игровые ресурсы я положил на карту памяти, в директорию /storage/sdcard1/AAAA-Data/, которую на данном этапе разработки просто захардкодил.
Первый запуск приложения на Android-устройстве опечалил меня сегфолтом. По старинке, с помощью расстановки логирующей функции аналогичной printf(), я нашёл проблемное место в коде. Им оказался вот этот сложный цикл в функции zrmloadtextures():
u32 texturepointer[128]; ... for (i = 0; i < 256; i++) { textureheader[i][0] = fgetc(fp); textureheader[i][1] = fgetc(fp); textureheader[i][2] = fgetc(fp); textureheader[i][3] = fgetc(fp); texturepointer[i] = ii; if (textureheader[i][0] > 0) { for (y = 0; y < textureheader[i][2]; y++) { for (x = 0; x < textureheader[i][1]; x++) { c = fgetc(fp); c0 = fgetc(fp); texturedata[ii++] = c * 256 + c0; } } texturereload[i] = 1; } }
Как видно, размер массива texturepointer был задан неверно. Примечательно то, что на десктопной версии игры эта проблема никак не проявлялась. О таких опасных местах в коде должны сообщать статические анализаторы, поэтому очень полезно проверять ими свои проекты. Кстати, эту же ошибку исправил разработчик, использующий ник senquack, который участвовал в портировании этой игры на консоль GCW Zero.
Игра теперь запустилась, но на экране устройства были лишь искажения на тёмном фоне. Я долго не мог понять, в чём причина такого поведения и уже начал грешить на проблемы с OpenGL ES, но потом обнаружил, что конфигурационный файл, который читает движок игры, имеет нулевой размер. Во время экспериментов по запуску AAAA, работа движка некорректно завершилась и конфигурационный файл был испорчен. Поэтому при чтении конфига все параметры выставлялись в нулевые значения, что и было следствием видимого шума на экране. Я просто заменил конфигурационный файл на корректный и игра запустилась правильно.
Далее я столкнулся с проблемой управления главным героем игры с помощью аппаратного D-Pad’а на моём Android-устройстве. Интересным было и то, что другие кнопки на физической клавиатуре работали без проблем. Я уже встречался с такой ситуацией, когда использовал библиотеку SDL2 версии 2.0.3, хотя в 2.0.4 эта проблема была исправлена. И вот, в версии 2.0.5, она вернулась опять. Внимательно исследовав код, я обнаружил, что D-Pad в SDL2 теперь подключается как джойстик и потому управление в AAAA, заточенное под использование клавиатуры, никак не реагирует на него. Красивого решения этой проблемы я так и не нашёл, и потому решил просто пропатчить файлы SDL2 и добавить дополнительные методы:
// --- SDL Patch Functions public static void pressOrReleaseKey(int keyCode, boolean press) { if (press) { SDLActivity.onNativeKeyDown(keyCode); } else { SDLActivity.onNativeKeyUp(keyCode); } } public static boolean convertJoyDpadToKeysFilter(int keyCode, boolean press) { switch (keyCode) { case DPAD_UP: pressOrReleaseKey(KC_SDL_UP, press); return true; case DPAD_DOWN: pressOrReleaseKey(KC_SDL_DOWN, press); return true; case DPAD_LEFT: pressOrReleaseKey(KC_SDL_LEFT, press); return true; case DPAD_RIGHT: pressOrReleaseKey(KC_SDL_RIGHT, press); return true; case DPAD_OK: pressOrReleaseKey(KC_SDL_A, press); return true; default: return false; } } // --- onKey() method in SDLActivity.java if (SDLActivity.isDeviceSDLJoystick(event.getDeviceId())) { // Note that we process events with specific key codes here if (event.getAction() == KeyEvent.ACTION_DOWN) { if (AAAAModernInputView.convertJoyDpadToKeysFilter(keyCode, true)) { return true; } if (SDLActivity.onNativePadDown(event.getDeviceId(), keyCode) == 0) { return true; } } else if (event.getAction() == KeyEvent.ACTION_UP) { if (AAAAModernInputView.convertJoyDpadToKeysFilter(keyCode, false)) { return true; } if (SDLActivity.onNativePadUp(event.getDeviceId(), keyCode) == 0) { return true; } } }
Метод-фильтр convertJoyDpadToKeysFilter(), используя вспомогательный метод pressOrReleaseKey(), отправляет события нажатий на клавиши «W», «A», «S», «D» и «Backspace» при использовании на D-Pad’е кнопок «вверх», «влево», «вниз», «вправо» и «ОК» соответственно. Если была нажата какая-либо другая кнопка, то её обработка идёт как нажатие на кнопку джойстика.
Рабочая инициализация контекста OpenGL ES в SDL2, в отличие от таковой на десктопе, проста и выглядит следующим образом:
#ifdef ANDROID_NDK SDL_GL_SetAttribute(SDL_GL_RED_SIZE, 8); SDL_GL_SetAttribute(SDL_GL_GREEN_SIZE, 8); SDL_GL_SetAttribute(SDL_GL_BLUE_SIZE, 8); SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 24); SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1); SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 0); SDL_GL_SetAttribute(SDL_GL_ACCUM_RED_SIZE, 0); SDL_GL_SetAttribute(SDL_GL_ACCUM_GREEN_SIZE, 0); SDL_GL_SetAttribute(SDL_GL_ACCUM_BLUE_SIZE, 0); SDL_GL_SetAttribute(SDL_GL_ACCUM_ALPHA_SIZE, 0); SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 1); SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 1); // TODO: Check this. SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_ES); globalWindow = SDL_CreateWindow("Adamant Armor Affection Adventure", SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED, screenwidth, screenheight, SDL_WINDOW_OPENGL); TO_DEBUG_LOG("Init SDL window: %dx%d\n", screenwidth, screenheight); SDL_SetWindowFullscreen(globalWindow, SDL_TRUE); SDL_GetWindowSize(globalWindow, &screenwidth, &screenheight); TO_DEBUG_LOG("Resize SDL window: %dx%d\n", screenwidth, screenheight); glContext_SDL = SDL_GL_CreateContext(globalWindow); glVertexPointer(3, GL_FIXED, 0, mesh); glTexCoordPointer(2, GL_FIXED, 0, mesht); glFogf(GL_FOG_MODE, GL_LINEAR); glAlphaFuncx(GL_GREATER, 65536 / 2); #endif
Изначально использовалась 16-битная глубина цветности формата RGB565, но немного поэкспериментировав, я выбрал 24-битную глубину формата RGB888.
Отличия можно явно посмотреть на иллюстрации выше. Как видно при приближении, 16-битная картинка имеет явные следы дизеринга, благодаря которому достигается эффект полноцветности изображения.
Так же как и авторы игры, в своём порте AAAA я задумал максимально задействовать современное железо Android-устройств, поэтому я решил достучаться до вибромотора и акселерометра. Функцию вибрации я вынес в отдельный файл android_extras.c, который компилируется только в Android-проекте:
void doVibrateFromJNI(int duration) { if (javaEnviron != NULL) { jclass clazz = (*javaEnviron)->FindClass(javaEnviron, "ru/exlmoto/aaaa/AAAAActivity"); if (clazz == 0) { TO_DEBUG_LOG("Error JNI: Class ru/exlmoto/aaaa/AAAAActivity not found!"); return; } jmethodID methodId = (*javaEnviron)->GetStaticMethodID(javaEnviron, clazz, "doVibrate", "(II)V"); if (methodId == 0) { TO_DEBUG_LOG("Error JNI: methodId is 0, method doVibrate (II)V not found!"); return; } // Call Java-method (*javaEnviron)->CallStaticVoidMethod(javaEnviron, clazz, methodId, (jint)duration, 1); // Delete Ref (*javaEnviron)->DeleteLocalRef(javaEnviron, clazz); } }
Благодаря механизму JNI, при вызове функции doVibrateFromJNI() из нативного кода, вызывается статический метод doVibrate() из Java-кода:
// JNI-method public static void doVibrate(int duration, int fromJNI) { // From JNI: 1 -- yes, 0 -- no // AAAASettings.configuration[10] is vibro haptics in game config // 30 is default scale for vibration if ((fromJNI == 1) && (AAAASettings.configuration[10] == 1)) { m_vibrator.vibrate(duration + (AAAASettings.vibroScale - 30)); } if ((fromJNI == 0) && (AAAASettings.touchVibration)) { m_vibrator.vibrate(duration + (AAAASettings.vibroScale - 30)); } }
Этот метод активирует вибромотор на заданный промежуток времени стандартными API в Android OS, если приложению даны соответствующие разрешения на использование вибрации. Получить значения с акселерометра оказалось ещё проще. Оказывается, в SDL2 есть внутренняя функция Android_JNI_GetAccelerometerValues(), она определена в заголовке src/core/android/SDL_android.h, относительно корня исходников SDL2. Таким образом, если в эту функцию передать массив из трёх float’ов, то в нём будут актуальные значения акселерометра по осям «x», «y» и «z»:
float accelValue[3]; ... Android_JNI_GetAccelerometerValues(accelValue); x = accelValue[0] * gsensor_scale; y = accelValue[1] * gsensor_scale; z = accelValue[2] * gsensor_scale;
Узнав приблизительный диапазон трёх осей акселерометра на GP2X Caanoo, я адаптирую в соответствии с ним собственные значения с помощью переменной gsensor_scale, благодаря которой движок без труда может использовать эти значения для своих нужд. Переменную gsensor_scale я решил сделать настраиваемой, чтобы была возможность тонкой регулировки интервалов отклонений.
После получения вполне работоспособной сборки AAAA для Android OS, я решил ради интереса поэкспериментировать и попробовать полностью отказаться от библиотеки SDL2. Для этого я отнаследовался от системного класса GLSurfaceView и реализовал интерфейс GLSurfaceView.Renderer, из нативного кода убрал функцию main(), перенёс инициализацию дисплея и шаг обновления экрана в отдельные функции. В общем, я использовал ту же стратегию, что и при портировании игры Spout. Результатом этого эксперимента стала сборка AAAA, не зависящая от SDL2, она без особых проблем запускалась в эмуляторе Android-устройства:
Конечно, там появилось множество побочных проблем. Например, переставала работать встроенная в движок система пропуска кадров и поэтому игра шла очень медленно. Кроме того, я лишился поддержки акселерометра и звуковой подсистемы, теперь всё это нужно было написать самому с нуля. Поэтому дело дальше экспериментов не продвинулось и решил вернуться на SDL2, а все изменения, связанные с вырезанием этой библиотеки, опубликовал в отдельной ветке vanilla_gles в репозитории с исходным кодом моего порта AAAA.
С реализацией сенсорного управления у меня всегда возникают трудности. Основная проблема в том, что в библиотеке SDL2 нет какого-либо стандартного способа отображения сенсорных элементов управления. Поэтому наиболее верное решение заключается в модифицировании движка самой игры таким образом, чтобы его подсистема, отвечающая за управление, реагировала ещё и на события сенсорного экрана, которые необходимо пробрасывать с помощью JNI. Кроме этого, движок должен отображать полупрозрачные элементы управления на экран и реагировать на прикосновения к ним. Написание и отладка подобного пласта кода требует очень много времени и затрат для каждой игры, поэтому я всегда иду наиболее лёгким путём и просто перекрываю игровой видеоконтекст прозрачным изображением с нарисованными сенсорными кнопками сверху:
У этого метода есть как несколько плюсов, так и минусов. Самый серьёзный недостаток, это небольшая просадка FPS на слабых Android-девайсах. Но он перекрывается возможностью быстрой кастомизации управления в необходимую мне сторону без надобности внесения изменений в сам игровой движок. Подобный тип управления я уже использовал в своём порте игры Ken’s Labyrinth и не буду касаться деталей его реализации здесь.
Такой вариант мне показался не слишком удобным для Adamant Armor Affection Adventure, поэтому я решил интегрировать в игру ещё один тип сенсорного управления, который я позаимствовал у разработчика, использующего ник [SoD]Thor. Примечательно, но он тоже начинал свой путь с тусовки GPH и разработал кучу интересных игр, в том числе и трёхмерных. Спросив у [SoD]Thor’а разрешение модифицировать его код, я получил положительный ответ и взялся за подгонку его наработок под AAAA, добавив несколько новых кнопок и немного поправив джойстик:
Отличительной особенностью такого управления является наличие сенсорного джойстика, который появляется при нажатии пальцем на соответствующую часть экрана. Отклоняя палец в стороны можно управлять главным героем или перемещаться по пунктам меню. Помимо этого, вариант управления от [SoD]Thor имеет индикацию нажатия, что позволяет увидеть, была нажата кнопка или нет. Основной недостаток остался прежним: слой с сенсорными элементами накладывается поверх игрового экрана, что негативно влияет на FPS, но заметно это лишь на старых и слабых устройствах, например, на Motorola Droid 2 и аналогичных ему по техническим характеристикам аппаратах. Ещё стоит заметить, что кнопки импровизированы, а нажатия обрабатываются в пределах прямоугольных регионов, на которые разделена рабочая область дисплея.
Класс AAAAModernInputView, в котором реализован этот вариант управления, является наследником системного класса View, что позволяет использовать оверлей с сенсорными элементами как обычный виджет, имея возможность накладывать его поверх других. Расчёт направлений отклонения и определение кнопок выполняется в методах getDirection() и getButton():
public int getDirection(int bx, int by) { int abx = bx - p0x; int aby = p0y - by; double lenab = Math.sqrt(abx * abx + aby * aby); if (lenab > radius) { p1x = p0x + (int) ((bx - p0x) * (radius / lenab)); p1y = p0y + (int) ((by - p0y) * (radius / lenab)); } else { p1x = bx; p1y = by; } if (lenab < (radius / 3)) { p1b = -1; } else { double rslt = aby == 0 ? (abx > 0 ? 0 : Math.PI) : Math.acos(abx / lenab); if (by > p0y) { rslt = (Math.PI * 2) - rslt; } p1b = ((int) ((rslt + Math.PI / 16) / (Math.PI / 4))) & 0x7; } return p1b; } public int getButton(int x, int y) { int b = -1; if (x < radius * 3 && y < radius) { b = KC_GAME_R; } else if (x > width - b_wh * 1.8) { if (y > (height / 3) * 2) { // Down b = KC_GAME_X; } else if (y < (height / 3)) { // Up b = KC_GAME_L; } else { // Center b = KC_GAME_A; } } else { if (y > (height / 3) * 2) { // Down b = KC_GAME_B; } else if (y < (height / 3)) { // Up b = KC_GAME_Y; } else { // Center b = KC_GAME_SELECT; } } return b; }
Для определения кнопок, экран делится на несколько частей по горизонтали и вертикали. При перемещении пальца в эти регионы функция возвращает индекс нажатой кнопки. Отрисовка элементов управления осуществляется в стандартном методе onDraw(), который перегружен у родительского класса View:
@Override protected void onDraw(Canvas canvas) { paint.setARGB(128, 255, 255, 255); paint.setFilterBitmap(true); paint.setAntiAlias(true); // Joystick if (p0id >= 0) { paint.setARGB(16, 255, 255, 255); canvas.drawCircle(p0x, p0y, radius << 1, paint); for (int i = 0; i < 8; ++i) { paint.setARGB(i == p1b ? 96 : 32, 255, 128, 128); canvas.drawCircle((float) (p0x + 7 * radius * Math.cos(i * Math.PI / 4) / 4), (float) (p0y - 7 * radius * Math.sin(i * Math.PI / 4) / 4), radius / 4, paint); } paint.setARGB(128, 255, 255, 255); canvas.drawCircle(p0x, p0y, radius / 3, paint); canvas.drawCircle(p1x, p1y, radius, paint); } // Buttons paint.setARGB(pid[KC_GAME_X] < 0 ? 64 : 160, 255, 255, 255); canvas.drawBitmap(button_x, col1_w, row1_h, paint); paint.setARGB(pid[KC_GAME_L] < 0 ? 64 : 160, 255, 255, 255); canvas.drawBitmap(button_l, col1_w, row2_h, paint); paint.setARGB(pid[KC_GAME_A] < 0 ? 64 : 160, 255, 255, 255); canvas.drawBitmap(button_a, col1_w, row3_h, paint); paint.setARGB(pid[KC_GAME_B] < 0 ? 64 : 160, 255, 255, 255); canvas.drawBitmap(button_b, col2_w, row1_h, paint); paint.setARGB(pid[KC_GAME_Y] < 0 ? 64 : 160, 255, 255, 255); canvas.drawBitmap(button_y, col2_w, row2_h, paint); paint.setARGB(pid[KC_GAME_SELECT] < 0 ? 64 : 160, 255, 255, 255); canvas.drawBitmap(button_s, col2_w, row3_h, paint); paint.setARGB(pid[KC_GAME_R] < 0 ? 64 : 160, 255, 255, 255); canvas.drawBitmap(button_r, rad_d2, rad_d2, paint); invalidate(); super.onDraw(canvas); }
Сенсорный джойстик просто состоит из нескольких примитивов в виде круга, которые собраны в единое целое. Сенсорные кнопки выводятся полупрозрачными изображениями, изменением параметра прозрачности у которых можно явно обозначить нажатия на них. Отлов и обработку событий сенсорного экрана можно посмотреть в исходном коде класса AAAAModernInputView, в репозитории игры на GitHub’е. Класс слишком объёмный, чтобы целиком публиковать его в этой статье.
Два этих варианта сенсорного управления уже были использованы мной в обновлённой версии порта игры Spout на Android OS. Стоит заметить, что общим и самым серьёзным недостатком вышеприведённых вариантов, является полное отсутствие кастомизации: невозможно изменить расположение сенсорных кнопок без модификации исходного кода проекта. В будущем, я планирую более тщательно проработать этот момент и добавить возможность свободного расположения элементов управления на экране.
Как уже было сказано ранее, на начальном этапе портирования Adamant Armor Affection Adventure, я разместил data-файлы на своей карте памяти, а путь к ним просто захардкодил в движке игры. Теперь настало время переработать этот момент и решить вопрос дистрибуции игрового кеша, общий размер которого составлял приблизительно 20 МБ. На первый взгляд, идеальным решением было поместить все эти файлы внутрь APK-пакета, чтобы не было никаких проблем с загрузкой дополнительных архивов и их распаковкой. Я начал экспериментировать и попробовал поместить кеш во внутреннюю директорию assets/ в APK-пакете. Благодаря использованию библиотеки SDL2 и стандартных функций для работы с файлами, определённых в заголовке SDL_rwops.h, я без особого труда смог получить доступ в эту директорию. Результатом этого эксперимента я остался недоволен: если запуск игры с кешем на карте памяти происходил чуть ли не мгновенно, то запуск с кешем из assets’ов затягивался на целую минуту. Стало понятно, что такое решение мне не подходит.
Тогда я решил посмотреть, как обходят эту проблему с медленным доступом к assets’ам другие игровые проекты. В интернете я нашёл игру Minetest, сборка которой была доступна и для Android. Оказывается, разработчики этой игры используют очень костыльный и некрасивый подход: при первом запуске Minetest они распаковывают кеш из assets’ов во внутреннее хранилище установленного приложения и получают доступ к файлам уже из него. Этот поход несколько раз подвергался критике со стороны пользователей, поскольку приложение начинает занимать вдвое больше места, а на устройстве хранения по сути имеется по две копии игровых файлов. Кроме того, ещё есть проблемы и с обновлениями: если APK-пакет и data-файлы в нём были обновлены, то игра всё равно будет использовать их старые копии во внутреннем хранилище. В обсуждении этого вопроса на GitHub, разработчики игры признают все проблемы и думают над решением этого вопроса. Тем не менее, последняя версия Minetest, на момент написания этой статьи, продолжает использовать распаковку кеша из assets’ов во внутреннее хранилище. Этот метод мной тоже был откинут, поскольку показался слишком непрактичным.
Мне ничего не оставалось, как избавится от навязчивой идеи запихнуть все игровые данные в единый APK-пакет и посмотреть в сторону специальных файлов формата OBB: APK Expansion Files. Дополнительным плюсом этого метода было то, что в Android, начиная с версии 2.3.x, была обеспечена их полная поддержка. Кеш OBB с необходимыми файлами монтируется в специальную директорию и Android-приложение получает туда доступ. Сделать такой файл можно с помощью утилиты jobb, которая входит в стандартную поставку Android SDK. Нужно просто определить директорию, задать версию, выходной файл и имя пакета:
# Create OBB file from assets_obb/ folder /opt/android/android-sdk/tools/jobb -d assets_obb/ -o main.1.ru.exlmoto.aaaa.obb -pn ru.exlmoto.aaaa -pv 1 # Create encrypted OBB file form assets_obb/ folder (don't working for me) /opt/android/android-sdk/tools/jobb -d assets_obb/ -o main.1.ru.exlmoto.aaaa.obb -k aaaa -pn ru.exlmoto.aaaa -pv 1
При желании, с помощью опции -k [password] игровые файлы можно зашифровать. Но стоит отметить, что такие образы у меня никак не монтировались. Мой первый эксперимент с OBB-кешем провалился, я попробовал из нативного кода подмонтировать этот файл и у меня ничего не получалось. Тогда мой друг, a1batross, посоветовал мне использовать для этой цели Java, а в нативный движок прокидывать путь, полученный после успешного монтирования OBB-кеша. Я прислушался к его совету и начал исследовать эту возможность, но и здесь меня ждали некоторые проблемы. Дело было в том, что эти OBB-файлы, согласно документации, должны распространяться через Google Play Store, незаметно для конечного пользователя. Я не имею планов публиковать порт AAAA в магазине приложений, поэтому я решил монтировать OBB-образ вручную.
На GitHub’е я наткнулся на замечательный проект android-obb-example от разработчика wiliwe, его исходный код окончательно ответил на все мои вопросы касательно монтирования отдельных OBB-файлов. Единственная проблема, с которой я не смог разобраться и про которую я уже говорил выше, состояла в невозможности монтирования зашифрованных паролем OBB-образов. Поскольку движок AAAA работает с открытыми для модификации data-файлами, то использовать зашифрованный OBB-кеш было бы странно, поэтому я опустил этот момент и не стал разбираться в причинах ошибки. Код из проекта android-obb-example я немного модифицировал и использовал для монтирования OBB-файла и получения пути для дальнейшего доступа к файлам:
private void checkObbMount() { obbFilePathName = editTextObbPath.getEditableText().toString(); File obbFile = new File(obbFilePathName); if (obbFile.exists() && obbFile.isFile()) { try { try { mObbInfo = ObbScanner.getObbInfo(obbFilePathName); } catch (IllegalArgumentException e) { e.printStackTrace(); showToast(R.string.obb_file_not_found, Toast.LENGTH_LONG); } catch (IOException e) { e.printStackTrace(); showToast(R.string.obb_file_not_read, Toast.LENGTH_LONG); } if (mStorageManager.mountObb(obbFilePathName, null, mObbEventListener)) { AAAAActivity.toDebugLog("Mount OBB file..."); } else { showToast(R.string.obb_mount_start_fail, Toast.LENGTH_LONG); } } catch (IllegalArgumentException e) { e.printStackTrace(); AAAAActivity.toDebugLog("The OBB file already mounted!"); } } else { showToast(R.string.obb_fail, Toast.LENGTH_LONG); } mObbInfo = null; obbFile = null; System.gc(); } OnObbStateChangeListener mObbEventListener = new OnObbStateChangeListener() { @Override public void onObbStateChange(String path, int state) { AAAAActivity.toDebugLog("ObbStateChanged, path: " + path + ", state: " + state); switch (state) { case OnObbStateChangeListener.ERROR_ALREADY_MOUNTED: AAAAActivity.toDebugLog("Obb File Already Mounted! Unmounting... #2"); mStorageManager.unmountObb(obbFilePathName, true, mObbEventListener); break; case OnObbStateChangeListener.MOUNTED: obbMountedPath = mStorageManager.getMountedObbPath(obbFilePathName); AAAAActivity.toDebugLog("Obb Mounted! Path: " + obbMountedPath); // Attempt to read the random file File readMeFile = new File(obbMountedPath + "/AAAA-Data/DONOTREAD.ME"); if (readMeFile.exists() && readMeFile.isFile()) { Intent intent = new Intent(aaaaLauncherActivity, AAAAActivity.class); startActivity(intent); } else { showToast(R.string.obb_files_read_error, Toast.LENGTH_LONG); } break; case OnObbStateChangeListener.UNMOUNTED: AAAAActivity.toDebugLog("Obb File Unmounted!"); break; default: break; } } };
Метод checkObbMount() проверяет наличие OBB-файла и пытается его смонтировать, на эту попытку реагирует специальный экземпляр класса OnObbStateChangeListener, который в случае удачного монтирования сохраняет путь к файлам в переменную obbMountedPath и запускает игру. Движок игры сразу после запуска с помощью JNI получает значение переменной obbMountedPath из Java и уже начинает работать именно с ним:
char *getObbMountedPath() { if (javaEnviron != NULL) { jclass clazz = (*javaEnviron)->FindClass(javaEnviron, "ru/exlmoto/aaaa/AAAALauncherActivity"); if (clazz == 0) { TO_DEBUG_LOG("Error JNI: Class ru/exlmoto/aaaa/AAAALauncherActivity not found!"); return NULL; } // Get Field ID jfieldID fieldID = (*javaEnviron)->GetStaticFieldID(javaEnviron, clazz, "obbMountedPath", "Ljava/lang/String;"); if (fieldID == 0) { TO_DEBUG_LOG("Error JNI: fieldID is 0, field String obbMountedPath not found!"); return NULL; } // Get String from Java and convert to char* jstring javaString = (*javaEnviron)->GetStaticObjectField(javaEnviron, clazz, fieldID); if (javaString == 0) { return NULL; } const char *nativeString = (*javaEnviron)->GetStringUTFChars(javaEnviron, javaString, 0); char *stringToAAAAEngine = strdup(nativeString); // Destroy string (*javaEnviron)->ReleaseStringUTFChars(javaEnviron, javaString, nativeString); // Delete Ref (*javaEnviron)->DeleteLocalRef(javaEnviron, clazz); // Return copy of string to Engine return stringToAAAAEngine; } else { return NULL; } }
Теперь, когда всё работало, я задумался о предоставлении пользователю возможности выбора необходимого OBB-образа из файловой системы Android-устройства. Одним из серьёзных недостатков Android OS является отсутствие стандартного файлового диалога. Каждое приложение использует собственноручно написанный велосипед. Изначально, в качестве диалога, я решил использовать возможности файловых менеджеров, предустановленных в систему с завода или установленных пользователем из магазина приложений самостоятельно. Заниматься подобными вещами в Android OS позволяет возможность запуска сторонних Activity из своего приложения. В теории это звучит достаточно интересно, но на практике обнаружилась огромная куча проблем, начиная с того, что некоторые системные менеджеры могут хитро кодировать пути до файлов и заканчивая вообще отсутствием предустановленных файловых менеджеров на некоторых устройствах.
Таким образом, я встал перед выбором: либо написать собственный костыль, либо найти проект с открытым исходным кодом, который уже решил эту проблему. Я выбрал второй путь и вспомнил про приложение от знакомой мне группы разработчиков FWGS — xash3d-android-project. Этот проект посвящён переносу альтернативного движка для игры Half-Life на различные платформы, в том числе и на Android OS. В исходном коде этого приложения имеется специальный файловый диалог для выбора директории с игровыми данными, написанный, судя по комментарию в его начале, программистом, использующим ник Solexid.
Я взял все необходимые файлы из проекта xash3d-android-project, модифицировал их должным образом, чтобы вместо директории иметь возможность выбора OBB-файла и результатом моих экспериментов стал вот такой симпатичный диалог:
Посмотреть его реализацию можно в классе AAAAFilePickerActivity, в репозитории моего порта игры AAAA. Иконки для него я взял из темы Adwaita проекта GNOME и немного их модифицировал. После этого я протестировал и отладил работу файлового диалога и монтирование OBB-образов на нескольких устройствах под управлением Android OS различных версий и остался вполне доволен полученным результатом.
Для того, чтобы пользователь мог удобно управлять самыми различными настройками игры Adamant Armor Affection Adventure, я создал специальный лаунчер, который украсил небольшой обложкой:
Я не буду подробно расписывать процесс его создания, эту информацию вы можете получить из моей статьи про портирование игры Spout. Скажу лишь одну интересную вещь: поскольку в главном меню самой игры имеется возможность изменения настроек, мне пришлось использовать два типа конфигурации: в виде конфига configuration и в виде обычных параметров. Это нужно было для того, чтобы процесс изменения настроек как из лаунчера, так и из главного меню AAAA был взаимосвязан. Класс AAAASettings, описывающий все параметры, выглядит следующим образом:
public static class AAAASettings { public static final int CFG_CNT = 32; public static final int MODERN_TOUCH_CONTROLS = 0; public static final int OLD_TOUCH_CONTROLS = 1; public static final int NO_TOUCH_CONTROLS = 2; // GAME CONFIG public static int[] configuration = { 1, 0, 0, 0, 0, 0, 1, 1, // 0-7: Menu Items 128, 48, // 8-9: Sound and Music Volume 1, 1, 2, 1, 0, 0, // 10-15: Vibrohaptics, G-Sensor, Frameskip, Noise, Reserve?, Reserve? 99, 59, 199, 59, 199, 59, 199, 59, 199, 59, 199, 59, 199, 59, 199, 59 // 16-31: Levels and Time }; // GAME SETTINGS public static int touchControls = MODERN_TOUCH_CONTROLS; public static boolean touchVibration = true; public static boolean frameLimit = false; // Access from JNI public static boolean aiDisable = false; // Access from JNI public static boolean showFps = false; // Access from JNI public static int gSensorScale = 1000; // Access from JNI public static int vibroScale = 30; public static String obbSavedPath = ""; }
Изначально, игра хранила свои настройки в специальном конфигурационном файле donothexedit.me, но, как я упоминал в самом начале статьи, запись параметров иногда отрабатывала некорректно и он получался нулевого размера. Я решил полностью избавиться от этого файла и перейти к использованию функций внутреннего хранилища конфигурации, которые предоставляет системный класс SharedPreferences. Для этого я немного модифицировал нативный код движка АААА, добавив туда три функции:
void readJavaConfigurationFromJNI() { if (javaEnviron != NULL) { jclass clazz = (*javaEnviron)->FindClass(javaEnviron, "ru/exlmoto/aaaa/AAAALauncherActivity$AAAASettings"); if (clazz == 0) { TO_DEBUG_LOG("Error JNI: Class ru/exlmoto/aaaa/AAAALauncherActivity$AAAASettings not found!"); return; } // Get configuration Field ID jfieldID fieldID = (*javaEnviron)->GetStaticFieldID(javaEnviron, clazz, "configuration", "[I"); if (fieldID == 0) { TO_DEBUG_LOG("Error JNI: fieldID is 0, field int[] configuration not found!"); return; } // Get array of integers jintArray intArray = (jintArray) (*javaEnviron)->GetStaticObjectField(javaEnviron, clazz, fieldID); if (intArray == 0) { TO_DEBUG_LOG("Error JNI: intArray is 0, field int[] configuration do not available!"); return; } // http://docs.oracle.com/javase/7/docs/technotes/guides/jni/spec/functions.html // Get<PrimitiveType>ArrayElements Routines // If isCopy is not NULL, then *isCopy is set to JNI_TRUE if a copy is made; // or it is set to JNI_FALSE if no copy is made. jint *cfg_array = (*javaEnviron)->GetIntArrayElements(javaEnviron, intArray, 0); int i, size = sizeof(configdata) / sizeof(configdata[0]); for (i = 0; i < size; ++i) { configdata[i] = cfg_array[i]; } // Destroy Int Array (*javaEnviron)->ReleaseIntArrayElements(javaEnviron, intArray, cfg_array, JNI_ABORT); // Delete Ref (*javaEnviron)->DeleteLocalRef(javaEnviron, clazz); } } void writeJavaConfigurationFromJNI() { if (javaEnviron != NULL) { jclass clazz = (*javaEnviron)->FindClass(javaEnviron, "ru/exlmoto/aaaa/AAAAActivity"); if (clazz == 0) { TO_DEBUG_LOG("Error JNI: Class ru/exlmoto/aaaa/AAAAActivity not found!"); return; } jmethodID methodId = (*javaEnviron)->GetStaticMethodID(javaEnviron, clazz, "writeConfiguration", "([I)V"); if (methodId == 0) { TO_DEBUG_LOG("Error JNI: methodId is 0, method writeConfiguration ([I)V not found!"); return; } // Create Int Array int i, size = sizeof(configdata) / sizeof(configdata[0]); jintArray cfg_array = (*javaEnviron)->NewIntArray(javaEnviron, size); // Copy Data from configdata to cfg_array jint *body_array = (*javaEnviron)->GetIntArrayElements(javaEnviron, cfg_array, 0); for (i = 0; i < size; ++i) { body_array[i] = configdata[i]; } // Set Region Array // On Dalvik works without calling this function, but for ART it is necessary // Otherwise, the array will be reset to zeros (*javaEnviron)->SetIntArrayRegion(javaEnviron, cfg_array, 0, size, body_array); // Call Java-method (*javaEnviron)->CallStaticVoidMethod(javaEnviron, clazz, methodId, cfg_array); // Destroy Int Array (*javaEnviron)->ReleaseIntArrayElements(javaEnviron, cfg_array, body_array, 0); // Delete Ref (*javaEnviron)->DeleteLocalRef(javaEnviron, clazz); } } void readOtherJavaSettingsFromJNI() { if (javaEnviron != NULL) { jclass clazz = (*javaEnviron)->FindClass(javaEnviron, "ru/exlmoto/aaaa/AAAALauncherActivity$AAAASettings"); if (clazz == 0) { TO_DEBUG_LOG("Error JNI: Class ru/exlmoto/aaaa/AAAALauncherActivity$AAAASettings not found!"); return; } // Get frameLimit Field ID jfieldID fieldID = (*javaEnviron)->GetStaticFieldID(javaEnviron, clazz, "frameLimit", "Z"); if (fieldID == 0) { TO_DEBUG_LOG("Error JNI: fieldID is 0, field boolean frameLimit not found!"); return; } jboolean frameLimit = (*javaEnviron)->GetStaticBooleanField(javaEnviron, clazz, fieldID); frame_limit = (int)frameLimit; // Get AI Disable Field ID fieldID = (*javaEnviron)->GetStaticFieldID(javaEnviron, clazz, "aiDisable", "Z"); if (fieldID == 0) { TO_DEBUG_LOG("Error JNI: fieldID is 0, field boolean aiDisable not found!"); return; } jboolean aiDisable = (*javaEnviron)->GetStaticBooleanField(javaEnviron, clazz, fieldID); ai_attack_disable_cheat = (int)aiDisable; // Get showFps Field ID fieldID = (*javaEnviron)->GetStaticFieldID(javaEnviron, clazz, "showFps", "Z"); if (fieldID == 0) { TO_DEBUG_LOG("Error JNI: fieldID is 0, field boolean showFps not found!"); return; } jboolean showFps = (*javaEnviron)->GetStaticBooleanField(javaEnviron, clazz, fieldID); fpsdisplay = (int)showFps; // Get gSensorScale Field ID fieldID = (*javaEnviron)->GetStaticFieldID(javaEnviron, clazz, "gSensorScale", "I"); if (fieldID == 0) { TO_DEBUG_LOG("Error JNI: fieldID is 0, field int gSensorScale not found!"); return; } jint gSensorScale = (*javaEnviron)->GetStaticIntField(javaEnviron, clazz, fieldID); gsensor_scale = (int)gSensorScale; // Delete Ref (*javaEnviron)->DeleteLocalRef(javaEnviron, clazz); } }
Процедура readOtherJavaSettingsFromJNI() просто считывает значения необходимых параметров из полей класса AAAASettings в Java-оболочке и переносит их в переменные нативного движка. Функции readJavaConfigurationFromJNI() и writeJavaConfigurationFromJNI() являются аналогом процедур чтения и записи конфигурационного файла. Чтение конфигурации происходит следующим образом: из Java-оболочки с помощью JNI считывается массив configuration и разворачивается в нативный массив configdata, с которым уже работает движок AAAA. Запись конфигурации происходит примерно аналогично, с тем лишь отличием, что нативный массив configdata передаётся аргументом в статический метод writeConfiguration() в Java, который выглядит следующим образом:
// JNI-method public static void writeConfiguration(int[] cfg) { for (int i = 0; i < AAAASettings.CFG_CNT; ++i) { AAAASettings.configuration[i] = cfg[i]; } AAAALauncherActivity.writeConfig(); }
Стоит отметить, что с передачей значений массива configdata в метод writeConfiguration() у меня возникли некоторые трудности. На Android OS версий 2.3.4 и 4.1.2 (там, где используется Dalvik) всё работало отлично, а на Android OS версий 6.0.1 и 7.1.1 (там, где используется ART) отправляемый массив был полностью обнулён. Решением этой проблемы стала функция SetIntArrayRegion(), определяющая необходимый регион массива конфигурации. Её вызов должен располагаться перед вызовом статического метода из Java-класса и отправкой в него массива.
Поскольку Adamant Armor Affection Adventure достаточно сложно проходить на мобильном устройстве, которое имеет только сенсорное управление, я решил добавить в лаунчер игры несколько читов. Первый открывает все заблокированные уровни, а второй отключает возможность нанесения урона главному герою противниками игры.
Кроме того, я увеличил скорость работы главного и побочных меню и немного подкорректировал баланс, уменьшив количество жизней некоторым врагам. После внесения этих изменений я без особого труда прошёл игру AAAA на устройстве Motorola Photon Q, используя его аппаратную клавиатуру.
Для сборки десктопной версии Adamant Armor Affection Adventure я решил использовать сборочную систему CMake и написал специальный сценарий CMakeLists.txt:
cmake_minimum_required(VERSION 2.8) project("AAAA-Engine") option(GLES "Enable OpenGL ES instead OpenGL" OFF) # Deploy Game Files and Executable. file(COPY ${CMAKE_CURRENT_SOURCE_DIR}/../../assets_obb/AAAA-Data/ DESTINATION ${CMAKE_CURRENT_BINARY_DIR}/${CMAKE_BUILD_TYPE}) set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/${CMAKE_BUILD_TYPE}) # Add Cmake path with additional modules. set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} ${CMAKE_CURRENT_SOURCE_DIR}/cmake) find_package(OpenGL REQUIRED) find_package(SDL2 REQUIRED) find_package(SDL2_mixer REQUIRED) include_directories(${PROJECT_NAME} ${OPENGL_INCLUDE_DIRS} ${SDL2_INCLUDE_DIR} ${SDL2_MIXER_INCLUDE_DIR}) if(GLES) message(STATUS "Build using OpenGLES libraries!") set(TARGET_NAME "AAAA-Engine-gles") find_package(OpenGLES REQUIRED) add_definitions(-DPC_GLES -DSDL2_PORT) include_directories(${INCLUDE_DIRECTORIES} ${OPENGLES_INCLUDE_DIR}) else() set(TARGET_NAME ${PROJECT_NAME}) add_definitions(-DPC_GL -DSDL2_PORT) endif() set(HEADERS AAAA-Engine/bullets.h AAAA-Engine/camera.h AAAA-Engine/drawmob.h AAAA-Engine/game.h AAAA-Engine/gamemenu.h AAAA-Engine/gamescene.h AAAA-Engine/intro.h AAAA-Engine/mobai.h AAAA-Engine/mobs.h AAAA-Engine/narrator.h AAAA-Engine/particles.h AAAA-Engine/playcontrol.h AAAA-Engine/render.h AAAA-Engine/sprites.h AAAA-Engine/vars.h AAAA-Engine/veryblend.h AAAA-Engine/wlight.h AAAA-Engine/wmapgen.h AAAA-Engine/wmapload.h AAAA-Engine/zcore.h AAAA-Engine/zcsound.h AAAA-Engine/zctables.h AAAA-Engine/zeditmode.h AAAA-Engine/zgui.h AAAA-Engine/zlext.h AAAA-Engine/zlmath.h AAAA-Engine/zresm.h AAAA-Engine/ztypes.h) set(SOURCES AAAA-Engine/bullets.c AAAA-Engine/camera.c AAAA-Engine/drawmob.c AAAA-Engine/game.c AAAA-Engine/gamemenu.c AAAA-Engine/gamescene.c AAAA-Engine/intro.c AAAA-Engine/main.c AAAA-Engine/mobai.c AAAA-Engine/mobs.c AAAA-Engine/narrator.c AAAA-Engine/particles.c AAAA-Engine/playcontrol.c AAAA-Engine/render.c AAAA-Engine/sprites.c AAAA-Engine/vars.c AAAA-Engine/veryblend.c AAAA-Engine/wlight.c AAAA-Engine/wmapgen.c AAAA-Engine/wmapload.c AAAA-Engine/zcore.c AAAA-Engine/zcsound.c AAAA-Engine/zctables.c AAAA-Engine/zeditmode.c AAAA-Engine/zgui.c AAAA-Engine/zlext.c AAAA-Engine/zlmath.c AAAA-Engine/zresm.c) add_executable(${TARGET_NAME} ${SOURCES} ${HEADERS}) target_link_libraries(${TARGET_NAME} ${SDL2_LIBRARY} ${SDL2_MIXER_LIBRARY} ${OPENGL_LIBRARIES} m) if(GLES) target_link_libraries(${TARGET_NAME} ${OPENGLES_LIBRARIES}) endif() # Show final message when build done. add_custom_target(finalMessage ALL ${CMAKE_COMMAND} -E cmake_echo_color --green "Output directory: ${CMAKE_CURRENT_BINARY_DIR}/${CMAKE_BUILD_TYPE}, executable: ${TARGET_NAME}." COMMENT "Final Message") add_dependencies(finalMessage ${TARGET_NAME})
Путём подстановки нужного значения в переменную GLES, можно собрать исполнительный файл с поддержкой OpenGL или OpenGL ES. Кроме того, скрипт разворачивает необходимые data-файлы в директорию сборки, поэтому после компиляции игру можно сразу запустить. Стоит отметить, что команда разработчиков CMake отказалась от политики предоставления модулей нахождения новых библиотек, поэтому я вручную разместил их в директории jni/src/cmake/, относительно корня проекта:
tree jni/src/cmake/ jni/src/cmake/ ├── FindOpenGLES.cmake ├── FindSDL2.cmake └── FindSDL2_mixer.cmake
Таким образом, сборка и запуск игры под GNU/Linux сводится к выполнению следующих команд:
mkdir build; cd build cmake jni/src/ -DCMAKE_BUILD_TYPE=Release -DGLES=Off make -j5 cd Release ./AAAA-Engine
CMake предоставляет возможность работы с исходным кодом движка игры AAAA из различных IDE, таких как Qt Creator и CLion:
Благодаря использованию подобных инструментов можно значительно повысить свою продуктивность и, например, использовать отладчик кода в интерактивном режиме.
Для создания стилизованной иконки приложения мне понадобилось распаковать игровые текстуры, находящиеся в файле textures.gfx, с которым работает движок игры. Формат этого файла был мне неизвестен, его поверхностный анализ в HEX-редакторе мне ничего особого не дал. Тогда я залез в исходный код Adamant Armor Affection Adventure и обнаружил, что текстуры в этом файле хранились как raw-данные, в формате GL_RGBA/GL_UNSIGNED_SHORT_4_4_4_4 и были слеплены в одну кучу.
Используя куски кода из движка AAAA, я решил написать простенький распаковщик текстур, который сохранял бы их в обычных изображениях PNG-формата. Для этого текстуру из RGBA4444 нужно было предварительно конвертировать в формат RGBA8888, после чего для сохранения PNG-изображения можно было использовать функцию lodepng_encode32_file() из маленькой библиотеки LodePNG. Для конвертации я воспользовался небольшой таблицей аппроксимации, которую нашёл на сайте GameDev.ru, являющимся самым популярным русскоязычным форумом для разработчиков игр. В итоге, моя реализация распаковщика на языке программирования C++, заняла меньше ста строк:
#include <experimental/filesystem> #include "lodepng/lodepng.h" typedef unsigned char uc; typedef unsigned short us; typedef unsigned int ui; typedef unsigned long ul; // RGBA4444 to RGBA8888 table by Frankinshtein // http://www.gamedev.ru/code/forum/?id=160094#m13 const uc lookupTable4to8[] = {0, 17, 34, 51, 68, 85, 102, 119, 136, 153, 170, 187, 204, 221, 238, 255}; uc textureheader[256][4], texturereload[256]; ui texturedata[1048576 * 5 / 2]; ul texturepointer[256]; us texturedata4444[65536]; ui texturedata8888[65536]; namespace fs = std::experimental::filesystem; void convertsavetexture(uc index, ui w, ui h) { ui ii = texturepointer[index], i = 0, x, y; for (y = 0; y < h; y++) { for (x = 0; x < w; x++) { texturedata4444[i++] = texturedata[ii++]; } } i = 0; ii = 0; for (y = 0; y < h; y++) { for (x = 0; x < w; x++) { us color4444 = texturedata4444[i++]; uc red = lookupTable4to8[(color4444 & 0xFFFF) >> 12]; uc green = lookupTable4to8[(color4444 & 0xFFF) >> 8]; uc blue = lookupTable4to8[(color4444 & 0xFF) >> 4]; uc alpha = lookupTable4to8[color4444 & 0xF]; ui color8888 = (alpha << 24) + (blue << 16) + (green << 8) + red; texturedata8888[ii++] = color8888; // std::bitset<32> v(rval); } } if (w > 0 && h > 0) { char fname[256] = {'\0'}; sprintf(fname, "unpacked/%03d.png", index); lodepng_encode32_file(fname, (uc *) texturedata8888, w, h); fprintf(stderr, "Unpacked texture #%03d, size: %dx%d\n", index + 1, w, h); } } int main(int argc, char *argv[]) { const char *dirname = "unpacked"; if (!fs::exists(dirname)) { fs::create_directory(dirname); } FILE *fp = fopen(argv[1], "rb"); ui i, ii = 0, x, y, w, h; uc c, c0; for (i = 0; i < 256; i++) { textureheader[i][0] = fgetc(fp); textureheader[i][1] = fgetc(fp); textureheader[i][2] = fgetc(fp); textureheader[i][3] = fgetc(fp); texturepointer[i] = ii; w = textureheader[i][1], h = textureheader[i][2]; if (textureheader[i][0] > 0) { for (y = 0; y < h; y++) { for (x = 0; x < w; x++) { c = fgetc(fp); c0 = fgetc(fp); texturedata[ii++] = c * 256 + c0; } } texturereload[i] = 1; } convertsavetexture(i, w, h); } fclose(fp); return 0; }
В функции convertsavetexture() имеется специальный цикл, который занимается раскладыванием каждого пикселя в RGBA4444 на составляющие и подбирает к ним необходимые значения из множества RGBA8888, представленных в таблице lookupTable4to8. Для компиляции, распаковки текстур и создания коллажа из них, нужно использовать следующие команды:
cd tools/gfx_unpacker/ git clone --depth 1 https://github.com/lvandeve/lodepng g++ unpack_gfx.cpp lodepng/lodepng.cpp -o unpack_gfx -lstdc++fs ./unpack_gfx ../../assets_obb/AAAA-Data/textures.gfx montage unpacked/*.png -background none tex_collage.png
Распакованные текстуры сохраняются в директории tools/gfx_unpacker/unpacked/, коллаж из текстур tex_collage.png, сделан с помощью утилиты montage, которая входит в консольный пакет программ для работы с изображениями ImageMagick.
Таким образом, я выполнил поставленную задачу и выдернул всю графику из файла textures.gfx, которая позволила мне создать иконку для приложения. Цели написать упаковщик текстур я перед собой не ставил, но не вижу особых сложностей в его реализации. Если у меня появится чуточку больше свободного времени, то я займусь и упаковщиком.
Портирование игры Adamant Armor Affection Adventure на Android OS дало мне огромное количество ценного опыта. В первую очередь стоит отметить то, что я научился работать с образами кешей в формате OBB. Кроме того, я убедился в том, что порт библиотеки SDL2_mixer на Android OS получился достаточно работоспособным. Я смог написать некоторые вспомогательные утилиты, решить множество интересных проблем и интегрировать в проект современный вариант сенсорного управления и файловый диалог.
Размер установочного APK-пакета получился весьма небольшим, всего лишь 1.7 МБ для трёх официально поддерживаемых библиотекой SDL2 архитектур: armeabi, armeabi-v7a и x86. Размер игрового кеша в формате OBB, как уже и было сказано выше, составляет 19.8 МБ.
Скачать APK-пакет Adamant Armor Affection Adventure, готовый для запуска на любом Android-устройстве, и кеш игры можно по этим ссылкам:
[Скачать | Download] — APK-пакет Adamant Armor Affection Adventure, v1.0, armeabi, armeabi-v7a, x86, 1.7 МБ.
[Скачать | Download] — OBB-кеш Adamant Armor Affection Adventure, v1.0a, 19.8 МБ.
[Скачать | Download] — OBB-кеш Adamant Armor Affection Adventure, v1.0b, 22.0 МБ.
Дополнительно установочные файлы можно получить со следующих зеркал: Yandex.Disk и GitHub Releases.
Все исходные коды и проект в целом выложен в репозиторий на ресурсе GitHub. Мои изменения и исходные файлы доступны под лицензией MIT. Ссылка на репозиторий:
https://github.com/EXL/AdamantArmorAffectionAdventure
В этой работе я использовал огромное количество материалов, основные из них я выделю в полезных ссылках ниже. Огромное спасибо ресурсам stackoverflow.com и google.com за то, что они есть.
Update 29-APR-2017: Пользователь форума GBX.ru, использующий ник Lock_Dock122, попросил меня сделать сборку AAAA для игрового смартфона Xperia PLAY и дал мне ссылку на специальный документ, в котором описывается назначение и код каждой физической клавиши на этом аппарате. Я немного подредактировал фильтр обработки кнопок и собрал APK-пакет под Xperia PLAY, который Lock_Dock122 успешно протестировал.
Игра была выложена на форум 4PDA, в специальную тему, посвящённую играм для Xperia PLAY. Чтобы не делать подобные сборки для аппаратов с различными и своеобразными элементами управления, мне было необходимо ранее заложить в приложение возможность задания аппаратных клавиш для различных действий игрока. Если у меня появится свободное время, я обязательно обновлю игру и реализую эту функциональность. Скачать версию AAAA для смартфона Xperia PLAY можно со страницы проектов.
Update 05-MAY-2017: Проект был переведён с устаревших технологий ant и Eclipse ADT на Gradle и Android Studio. Это ознаменовало выход новой версии Adamant Armor Affection Adventure v1.1, скачать которую можно со странички проектов.
]]>Постепенно, для некоторых приложений, над которыми я работал, накопились важные обновления, исправления некоторых ошибок и другие патчи. Я решил описать все изменения тоже в этой статье. Таким образом, она будет представлять собой небольшой дайджест произошедшего примерно за четыре месяца и будет довольно объёмной и полной. Больше всего изменений получилось для моих приложений на Android OS, поэтому я вынесу информацию о них в конец этого поста. Кроме того, обновления затронули и некоторые другие проекты, об этом тоже будет написано подробнее. Но начнём с отдельных приложений.
1. Приложение Synergy Calls для Android OS
2. Демон keyd для MotoMAGX
3. Новые сборки Cave Story (NXEngine)
4. Обновление блога и темы Moto Juice
5. Обновление Telegram-бота Gadget Hackwrench (DigestBot)
6. Небольшой твик Bezier Clock для KDE Plasma 5
7. Обновление AstroSmash до версии 1.1
8. Обновление Ken’s Labyrinth до версии 1.1
9. Обновление Snooder 21 до версии 1.1
10. Обновление Spout до версии 1.1
11. Итог и заключение
Мой знакомый Web-программист, использующий ник Synergy, попросил меня сделать небольшую программку для Android OS. Это приложение должно было отлавливать все входящие и исходящие звонки, собирать о них информацию, и отправлять всё это на указанный сервер посредством метода запроса POST. Днём я приступил к написанию программы и уже вечером получил первую рабочую версию приложения.
После непродолжительных поисков в интернете, на ресурсе Stack Overflow за авторством Uma Kanth я нашёл отличный класс PhoneCallReceiver, который избавил меня от написания собственного велосипеда. Я протестировал этот класс и решил использовать его в качестве ядра программы.
Чтобы сервис приложения стартовал сразу после запуска смартфона, я отнаследовался от системного класса BroadcastReceiver и перегрузил метод onReceive() следующим образом:
public class BootReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { Intent startServiceIntent = new Intent(context, SynergyService.class); context.startService(startServiceIntent); } }
А для того, чтобы операционная система Android не выгружала со временем моё приложение из памяти устройства, я создал сервис SynergyService, отнаследовавшись от системного класса Service. Далее, в методе onCreate() я создаю экземпляр класса CallReceiver. Всё это выглядит приблизительно следующим образом:
public class SynergyService extends Service { public static final String LOG_TAG = "SynergyService"; private CallReceiver callReceiver = null; public void onCreate() { super.onCreate(); Log.d(LOG_TAG, "onCreate"); callReceiver = new CallReceiver(); SharedPreferences sharedPreferences = getSharedPreferences("ru.exlmoto.synergycall", MODE_PRIVATE); callReceiver.updateValues(sharedPreferences); } ... }
Класс CallReceiver я унаследовал от упомянутого выше класса PhoneCallReceiver, перегрузив в нём соответствующие методы. Кроме того, в CallReceiver я создал вложенный класс PostClass, унаследованный от системного класса AsyncTask, который и занимается отправкой данных на сервер в виде POST-запроса:
public class CallReceiver extends PhoneCallReceiver { private class PostClass extends AsyncTask<String, Void, Void> { private final Context context; private final String a_url; private final String value; private final Date time; private final Date timeEnd; private final String time_s; private final String timeEnd_s; public PostClass(Context c, final String a_url, final String value, final Date time, final Date timeEnd) { this.context = c; this.a_url = a_url; this.value = value; this.time = time; this.timeEnd = timeEnd; DateFormat df = new SimpleDateFormat("dd-MM-yyyy_HH:mm:ss"); time_s = df.format(time); if (timeEnd != null) { timeEnd_s = df.format(timeEnd); } else { timeEnd_s = ""; } Log.d(APP_TAG, "POST: " + this.a_url + " " + this.value + " " + this.time + " " + this.timeEnd); } protected void onPreExecute() { } @Override protected Void doInBackground(String... params) { try { URL url = new URL(a_url); HttpURLConnection connection = (HttpURLConnection)url.openConnection(); String urlParameters = "phone=" + value + ";"; urlParameters += "time=" + time_s; if (timeEnd != null) { urlParameters += ";time_end=" + timeEnd_s; } connection.setRequestMethod("POST"); // connection.setRequestProperty("USER-AGENT", "Mozilla/5.0"); // connection.setRequestProperty("ACCEPT-LANGUAGE", "en-US,en;0.5"); connection.setDoOutput(true); DataOutputStream dStream = new DataOutputStream(connection.getOutputStream()); dStream.writeBytes(urlParameters); dStream.flush(); dStream.close(); int responseCode = connection.getResponseCode(); final StringBuilder output = new StringBuilder("Request URL " + url); output.append(System.getProperty("line.separator") + "Request Parameters " + urlParameters); output.append(System.getProperty("line.separator") + "Response Code " + responseCode); output.append(System.getProperty("line.separator") + "Type " + "POST"); BufferedReader br = new BufferedReader(new InputStreamReader(connection.getInputStream())); br.close(); } catch (MalformedURLException e) { // TODO Auto-generated catch block e.printStackTrace(); } catch (IOException e) { // TODO Auto-generated catch block e.printStackTrace(); } return null; } } public static final String APP_TAG = "CallReceiver"; public static String inUrl; public static String inEUrl; public static String outUrl; public static String outEUrl; public static String missUrl; @Override protected void onIncomingCallStarted(Context ctx, String number, Date start) { Log.d(APP_TAG, "Incoming: " + number); new PostClass(ctx, inUrl, number, start, null).execute(); } @Override protected void onOutgoingCallStarted(Context ctx, String number, Date start) { Log.d(APP_TAG, "Outgoing: " + number); new PostClass(ctx, outUrl, number, start, null).execute(); } @Override protected void onIncomingCallEnded(Context ctx, String number, Date start, Date end) { Log.d(APP_TAG, "Incoming end: " + number); new PostClass(ctx, inEUrl, number, start, end).execute(); } @Override protected void onOutgoingCallEnded(Context ctx, String number, Date start, Date end) { Log.d(APP_TAG, "Outgoing end: " + number); new PostClass(ctx, outEUrl, number, start, end).execute(); } @Override protected void onMissedCall(Context ctx, String number, Date start) { Log.d(APP_TAG, "Missed: " + number); new PostClass(ctx, missUrl, number, start, null).execute(); } public static void updateValues(SharedPreferences settingsStorage) { Log.d(APP_TAG, "Update Values from SharedPreferences"); inUrl = settingsStorage.getString("in", "http://exlmoto.ru"); inEUrl = settingsStorage.getString("inE", "http://exlmoto.ru"); outUrl = settingsStorage.getString("out", "http://exlmoto.ru"); outEUrl = settingsStorage.getString("outE", "http://exlmoto.ru"); missUrl = settingsStorage.getString("miss", "http://exlmoto.ru"); Log.d(APP_TAG, "inUrl: " + inUrl); Log.d(APP_TAG, "inEUrl: " + inEUrl); Log.d(APP_TAG, "outUrl: " + outUrl); Log.d(APP_TAG, "outEUrl: " + outEUrl); Log.d(APP_TAG, "missUrl: " + missUrl); } }
Собственно, здесь всё просто. При поступлении любого вызова на смартфон, управление передаётся в перегруженные методы, в которых выполняется асинхронная задача, отправляющая подготовленный POST-запрос на сервер, который логирует всю поступающую информацию.
Для удобного управления опциями программы был создан простой GUI, позволяющий задавать нужные адреса для отправления POST-запросов и сохранять их. Функциональность интерфейса реализована в Activity-классе MainActivity, который помимо этого запускает сервис SynergyService, если он не был запущен:
@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); startService(new Intent(this, SynergyService.class)); settingStorage = getSharedPreferences("ru.exlmoto.synergycall", MODE_PRIVATE); // Check the first run // boolean firstRun = false; if (settingStorage.getBoolean("firstRun", true)) { // firstRun = true; settingStorage.edit().putBoolean("firstRun", false).commit(); } else { readSettings(); } initWidgets(); fillLayoutBySettings(); button.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { writeSettings(); Toast.makeText(v.getContext(), "Settings Saved", Toast.LENGTH_SHORT).show(); } }); }
На Android OS версии 6.0 и выше, может потребоваться разрешить приложению Synergy Calls получать информацию о вызовах в настройках. Исходный код программы выложен на GitHub под лицензией MIT:
https://github.com/EXL/SynergyCalls
Скачать готовый APK-пакет можно по ссылке ниже:
[Скачать APK-пакет Synergy Calls, v1.0, 43 КБ | Download Synergy Calls APK-package, v1.0, 43 КB]
Надеюсь, это приложение пригодится и окажется полезным не только моему другу.
Update 05-MAY-2017: В проекте были обновлены компоненты Gradle, это ознаменовало выход новой версии Synergy Calls v1.1, скачать которую можно со странички проектов.
Пользователь форума на ресурсе MotoFan.Ru, использующий ник fill.sa, попросил меня разобраться в том, почему на телефоне Motorola ZN5 не работает демон (программа, работающая в фоновом режиме) vr, который испаноговорящие разработчики из Аргентины написали специально для Motorola Z6. Эта программа используется для отлова нажатия на голосовую кнопку и для вызова соответствующей программы после этого действия.
Поверхностное тестирование, которое провёл владелец ZN5 под ником VINRARUS, помогло мне выявить следующее: в канал
QCopChannel("/hardkey/bc")и в некоторые другие протестированные каналы, посылаются данные только о нескольких нажатых кнопках во время работы аппарата, поэтому эта программа не работает должным образом на Motorola ZN5. Нужно было найти, во-первых, рабочее, а во-вторых, более кросс-платформенное решение проблемы отлова нажатий на кнопки. Спустя много часов поисков в Google, я нашёл примерный вариант решения на GitHub’е в проекте OPIE, о котором я уже упоминал.
Поскольку в MotoMAGX используется системная библиотека Qt/Embedded версии 2.3.8, то там есть собственный небольшой оконный сервер, называемый QWS. В Motorola решили пойти по пути наименьшего сопротивления и оставить его. Но тем не менее, в MotoMAGX сервер QWS немного изменён и выделен в отдельную программу, код которой закрыт, а называется она просто windowsserver. Но это неважно, так как мы можем отнаследоваться от класса ZApplication и перегрузить виртуальный метод qwsEventFilter(), в котором можно будет отлавливать информацию, которая передаётся через сервер. В данном случае нас интересуют только кнопки:
virtual bool qwsEventFilter(QWSEvent *event) { if (event->type == QWSEvent::Key) { QWSKeyEvent *keyEvent = static_cast<QWSKeyEvent *>(event); #ifdef DEBUG_LOG qDebug(QString("win: %1, unicode: %2, keycode: %3, modifier: %4, press: %5, repeat: %6") .arg(keyEvent->simpleData.window) .arg(keyEvent->simpleData.unicode) .arg(keyEvent->simpleData.keycode) .arg(keyEvent->simpleData.modifiers) .arg(keyEvent->simpleData.is_press) .arg(keyEvent->simpleData.is_auto_repeat)); #endif if (keyEvent->simpleData.is_press == 1 && keyEvent->simpleData.is_auto_repeat == 0) { if (keyEvent->simpleData.keycode != 65286) { vibThread->start(); } // Drop Camera Shutter catchButton(keyEvent->simpleData.keycode, keyEvent->simpleData.is_press); } } return true; }
Здесь всё просто: фильтруем все события сервера и выбираем относящиеся к клавиатуре, затем приводим event к нужному типу и через структуру simpleData получаем доступ к необходимым и достаточно полным данным, вроде кода клавиши, статуса её удержания и прочей сопутствующей информацией, благодаря которой мы можем вызвать вибрацию в отдельном потоке, если это необходимо, а также обработать нажатие в методе catchButton():
void catchButton(uint keycode, uint is_press) { if (is_press) { QString system_call = config->find(keycode).data(); if (system_call) { system(QString("%1 %2").arg(system_call).arg(" &").ascii()); } } }
Вызов системной программы осуществляется с помощью функции system(), а для того, чтобы управление после запуска программы снова вернулось в демон, в конце строки передаётся амперсанд.
Кстати, этот трюк с QWS был задокументирован лишь в Qt 3, в Qt 2 про него абсолютно ничего не говорится. Я соединил способ описанный выше со старым отловом нажатий кнопок через канал
QCopChannel("/hardkey/bc")и получил то, что мне требовалось. Мониторинг нажатий через каналы реализуется ещё проще:
void registerChannels() { if (QCopChannel::isRegistered("/hardkey/bc")) { bcChannel = new QCopChannel("/hardkey/bc", this); connect(bcChannel, // <- Throws event SIGNAL(received(const QCString &, const QByteArray &)), this, // <- Catch event SLOT(catchCoButton(const QCString &, const QByteArray &))); } } void catchCoButton(const QCString &message, const QByteArray &data) { QDataStream stream(data, IO_ReadOnly); if (message == "keyMsg(int,int)") { int key, type; stream >> key >> type; #ifdef DEBUG_LOG qDebug(QString("key: %1, type: %2").arg(key).arg(type)); #endif if (key == 65285) { // Gallery Key vibThread->start(); catchButton(key, type); } } }
В классе Application есть методы registerChannels() и catchCoButton(). Первый вызывается в функции main() сразу после создания объекта, а второй является слотом, то есть вызывается сразу после сигнала получения события и отлавливает и обрабатывает нажатие на кнопку галереи.
Для настройки демона keyd я решил реализовать возможность чтения конфигурационного файла keyd.cfg следующего вида:
## Vibration # Syntax: <on/off>:<duration>, 0 - off, 1 - on, :x - duration in msec 1:150 ## Commands # Syntax: <keycode>:<shell command> 4144:/mmc/mmca1/my_script.sh 4145:/mmc/mmca1/my_executable
Решётка в начале строки означает комментарий, а дальше идут опции, разделённые знаком двоеточия. В первой колонке указывается код требуемой кнопки, а во второй — путь до исполняемого файла или скрипта в системе, который нужно запустить по нажатию на неё. Для чтения значений из этого файла я создал специальный класс ShittyCfgParser:
class ShittyCfgParser { int error; bool vibro; int vibrodur; QString cfgData; QMap<int, QString> configMap; void parseConfigData() { configMap.clear(); QStringList configList = QStringList::split('\n', cfgData); int count_config_lines = 0; for (uint i = 0; i < configList.count(); ++i) { QString configStr = configList[i]; if (configStr.startsWith("#")) { continue; } else if (configStr[0].isDigit()) { count_config_lines++; QStringList tokenizeLine = QStringList::split(':', configStr); if (tokenizeLine.count() == 2) { if (count_config_lines == 1) { // First element for vibration vibro = tokenizeLine[0].toInt(); vibrodur = tokenizeLine[1].toInt(); } else { configMap.insert(tokenizeLine[0].toInt(), tokenizeLine[1]); } } } } error = CONFIG_OK; } public: ShittyCfgParser(const QString &fileName) : error(CONFIG_ERROR) { readFileData(fileName); } ~ShittyCfgParser() { } int getError() const { return error; } bool getVibro() const { return vibro; } int getVibroDur() const { return vibrodur; } QMap<int, QString> *getConfigMap() const { return const_cast<QMap<int, QString> *>(&configMap); } void readFileData(const QString &fileName) { QFile configFile(fileName); if (configFile.exists()) { if (configFile.open(IO_ReadOnly)) { int size = configFile.size(); char data[size]; configFile.readBlock(data, sizeof(data)); cfgData = data; parseConfigData(); } else { qDebug(QString("Error opening file: %1.").arg(fileName)); } } else { qDebug(QString("Error: config file: %1 doesn't exist.").arg(fileName)); } configFile.close(); } };
После своего открытия файл считывается в массив data, который потом копируется в QString, после чего вызывается метод parseConfigData(), разбирающий данные и собирающий QMap, значения строк запуска приложений из которого можно получить по ключу-коду кнопки. Благодаря использованию конфигурационного файла появилась возможность настройки демона под свои нужды без перекомпиляции.
Этот проект является больше каркасом, скелетом или конструктором для последующего изменения, чем готовым для использования приложением. Например, VINRARUS создал на основе демона keyd приложение zBookmark, которое по своей функциональности напоминает метки в P2K-телефонах.
Исходный код приложения keyd выложен на GitHub под лицензией Public Domain:
Скачать TAR.GZ-пакет с исполняемыми файлами для телефонов MotoMAGX можно по ссылке ниже:
[Скачать TAR.GZ-пакет keyd, v1.0, 35 КБ | Download keyd TAR.GZ-package, v1.0, 35 КB]
Приложение можно использовать по своему усмотрению, изменять или наращивать функциональность в нём. Кстати, таким трюком с QWS мне нужно будет попробовать отловить и другие типы событий оконного сервера, например, будет полезно поймать пересылаемую информацию в каналы, если это, конечно, возможно.
Два года назад мне подарили Motorola ROKR E6 и я решил портировать замечательную игру Cave Story на движке NXEngine и на платформу EZX. Поскольку в SDK для этой платформы использовался очень старый компилятор GCC 3.3.6, то у меня возникли некоторые затруднения с выравниванием структур, так как sizeof(), применимый к ним, давал совершенно другой результат. Я сделал несколько исправлений в коде, чтобы исправить некоторые вылеты, но из-за нехватки свободного времени отложил этот порт и вспомнил о нём лишь недавно. Первое тестирование собранных пакетов дало положительные результаты, я без проблем смог пройти игру до уровня «Грасстаун» и дальше решил не продолжать прохождение. Поскольку собранные пакеты для платформы EZX оказались вполне рабочими, я решил опубликовать их.
Администраторы фанатского сайта www.cavestory.org нашли в интернете мой проект по портированию NXEngine на разные устройства и добавили собранные мной пакеты для различных платформ на свой сайт, в раздел загрузок, за что им огромное спасибо. Позже со мной связался поклонник игры Cave Story — Ola Andersson, который попросил меня обновить английскую версию NXEngine под MS Windows (32-bit) до версии 1.0.0.6 и посодействовать продвижению форка NXEngine-evo на фанатский сайт, попутно исправив некоторые баги в нём. Все эти вопросы удалось решить и уладить, для этого я связался с iSage (автором NXEngine-evo), авторами www.cavestory.org и результатом нашей работы стали обновлённые и добавленные пакеты различных портов NXEngine на этом полуофициальном и популярном сайте поклонников Cave Story.
Более подробную информацию об этих событиях можно посмотреть в соответствующем разделе статьи про Cave Story (NXEngine), там же можно скачать и готовые для установки пакеты.
С момента обновления дизайна сайта я сделал несколько незначительных изменений и дополнений, опишу здесь лишь основные. Во-первых, на сайт была добавлена новая страница Projects, на которой будут публиковаться все основные и актуальные приложения, над которыми я работаю. Кроме того, виджет с пятью случайными проектами в шапке сайта получил дополнительную кнопку со стрелкой, которая тоже ведёт эту на страницу со списком пакетов приложений.
Моя статья EasyCAP USB 2.0 — Обзор и опыт использования за пять лет набрала очень большое количество комментариев, что привело к общему замедлению скорости загрузки страницы с обзором. Поэтому я решил включить в движке блога WordPress разбивку комментариев на страницы и заодно активировать более интересные аватары пользователей в формате Wavatar, раз уж скорость отображения после сокращения количества показываемых комментариев резко возросла. Примечательно, что новые аватары немного похожи на персонажей игры Snood 21.
Но мной была выявлена небольшая проблема: моя текущая тема MotoJuice, унаследованная от стандартного шаблона Twenty Fourteen, имела достаточно странную пагинацию комментариев без обозначения номера страницы. Пришлось вернуться на стандартную пагинацию следующим образом:
diff --git a/comments.php b/comments.php index 781c06d..a718841 100644 --- a/comments.php +++ b/comments.php @@ -32,8 +32,7 @@ if ( post_password_required() ) { <?php if ( get_comment_pages_count() > 1 && get_option( 'page_comments' ) ) : ?> <nav id="comment-nav-above" class="navigation comment-navigation" role="navigation"> <h1 class="screen-reader-text"><?php _e( 'Comment navigation', 'twentyfourteen' ); ?></h1> - <div class="nav-previous"><?php previous_comments_link( __( '← Older Comments', 'twentyfourteen' ) ); ?></div> - <div class="nav-next"><?php next_comments_link( __( 'Newer Comments →', 'twentyfourteen' ) ); ?></div> + <div class="paginate-comments"><?php paginate_comments_links(); ?></div> </nav><!-- #comment-nav-above --> <?php endif; // Check for comment navigation. ?> @@ -50,8 +49,7 @@ if ( post_password_required() ) { <?php if ( get_comment_pages_count() > 1 && get_option( 'page_comments' ) ) : ?> <nav id="comment-nav-below" class="navigation comment-navigation" role="navigation"> <h1 class="screen-reader-text"><?php _e( 'Comment navigation', 'twentyfourteen' ); ?></h1> - <div class="nav-previous"><?php previous_comments_link( __( '← Older Comments', 'twentyfourteen' ) ); ?></div> - <div class="nav-next"><?php next_comments_link( __( 'Newer Comments →', 'twentyfourteen' ) ); ?></div> + <div class="paginate-comments"><?php paginate_comments_links(); ?></div> </nav><!-- #comment-nav-below --> <?php endif; // Check for comment navigation. ?>
И внести соответствующие поправки в стили CSS.
Ранее я занимался разработкой и поддержкой темы MotoJuice с помощью обычного редактора Kate, но недавно я успешно импортировал проект темы в интегрированную среду разработки NetBeans и сразу исправил достаточно серьёзную опечатку в стилях, которую нашёл мне встроенный парсер этой IDE. Из-за забытой запятой (на восьмой строке ниже) одно CSS-правило попросту игнорировалось браузерами.
.entry-content a:hover, .entry-summary a:hover, .page-content a:hover, .comment-content a:hover { text-decoration: underline; } , .entry-content a.button, .entry-summary a.button, .page-content a.button, .comment-content a.button { text-decoration: none; }
Одно из важных преимуществ IDE состоит в том, что они помогают бороться с такими вот опечатками и невнимательностью программиста.
С момента создания дайджест-бота Gadget Hackwrench прошло почти два года и он до сих пор работает и обслуживает нашу группу в Telegram. Кроме того, функциональность программы благодаря стараниям моего друга Zorge.R сильно увеличилась, например, добавилась возможность вывода графиков котировок валют и металлов, отправка стикеров по ID, мониторинг игровых серверов и множество других полезных функций.
Кроме того, к боту была прикреплена простейшая система сохранения дайджест-лога с поддержкой ротации, которая поможет восстановить данные после какого-нибудь факапа. Это уже помимо того, что каждое сообщение с тегом #digest пишется на диск сервера. Итак, основную работу выполняет небольшой Bash-скрипт:
#!/bin/bash DAYMONTH=`date +"%Y%m%d_%H%M%S"` /bin/tar -czf /root/digestbot/BackUpDigestLog/backup_digest_log_$DAYMONTH.tar.gz -C /root/digestbot/DigestBot/ DigestBotStackLog.json # Backup rotation, default 100 files FILES=100 ls -1 /root/digestbot/BackUpDigestLog/backup_* | sort -r | tail -n +$(($FILES+1)) | xargs rm > /dev/null 2>&1
При выполнении скрипт производит упаковку истории в архив с именем, в котором содержится дата и время. Затем скрипт проверяет количество файлов в директории, если их количество больше числа в переменной FILES, то он удаляет все старые файлы, выходящие за верхний предел этого числа. Это называется ротацией логов.
Чтобы этот скрипт отрабатывал в нужное время, используется функциональность системы инициализации systemd, для которой был создан юнит digestbotbackup.service:
[Unit] Description=Backup of DigestBot Stack Log [Service] Type=simple ExecStart=/root/backup_digest_log.sh [Install] WantedBy=multi-user.target
И специальный таймер digestbotbackup.timer:
[Unit] Description=Timer for DigestBot Log Stack BackUp [Timer] OnCalendar=*-*-* 22:00:00 Unit=digestbotbackup.service [Install] WantedBy=multi-user.target
Благодаря которому сервис вызывает скрипт один раз в сутки, в десять часов вечера. Информации о юнитах и таймерах в systemd в интернете полно и поиск нужного руководства по их настройке и использованию не вызывает сложностей.
Моя давняя статья про написание простейших ботов для Telegram на JavaScript и Python за это время немного потеряла в своей актуальности, но базовые принципы создания подобных ботов для мессенджера Telegram там всё ещё описаны достаточно подробно. Если этого недостаточно, то исходный код текущей реализации дайджест-бота можно посмотреть на GitHub’е, а скачать готовый пакет для Node.js можно по этой ссылке:
[Скачать ZIP-пакет DigestBot, v1.0.6, 337 КБ | Download DigestBot ZIP-package, v1.0.6, 337 КB]
[Скачать ZIP-пакет DigestBot, v1.0.5, 309 КБ | Download DigestBot ZIP-package, v1.0.5, 309 КB]
[Скачать ZIP-пакет DigestBot, v1.0.4, 204 КБ | Download DigestBot ZIP-package, v1.0.4, 204 КB]
Руководство по запуску, установке и развертыванию в системе этого бота опубликовано в README.md файле на GitHub’е.
Пользователь чата MotoFan.Ru в Telegram, использующий ник J()KER, попросил меня немного поправить часы на кривых Безье для рабочего стола KDE Plasma 5, создание которых я описывал в этой статье. Суть исправления заключалась в возможности установки собственного изображения в качестве фонового. Я решил предоставить ему небольшой концепт подобного обновления, который сделал буквально за минуту.
diff --git a/qml/BezierCanvas.qml b/qml/BezierCanvas.qml index 5e68cfa..294accd 100644 --- a/qml/BezierCanvas.qml +++ b/qml/BezierCanvas.qml @@ -43,7 +43,8 @@ Canvas { context.save(); // Fill Background - CanvasFunctions.fillCanvasByColor(context, setup.backgroundColor); + // CanvasFunctions.fillCanvasByColor(context, setup.backgroundColor); + context.clearRect(0, 0, canvas.width, canvas.height); // Translate Context CanvasFunctions.translateContex(context); diff --git a/qml/BezierClock.qml b/qml/BezierClock.qml index f6f748d..35d0545 100644 --- a/qml/BezierClock.qml +++ b/qml/BezierClock.qml @@ -28,6 +28,8 @@ Rectangle { width: 2300 * setup.visualScaling height: 600 * setup.visualScaling + color: "transparent" + BezierCanvas { id: canvas anchors.fill: parent diff --git a/qml/main.qml b/qml/main.qml index cec95b5..eff7841 100644 --- a/qml/main.qml +++ b/qml/main.qml @@ -24,11 +24,12 @@ import QtQuick 2.0 -Rectangle { +import org.kde.plasma.core 2.0 as Plasmacore + +Image { id: root - width: 800 - height: 480 - color: setup.backgroundColor + fillMode: wallpaper.configuration.FillMode + source: "http://wstaw.org/m/2017/03/02/wall_1.png" BezierSettings { id: settings
Суть изменений заключается в следующем: в файле main.qml корневой элемент изменяется с Rectangle на Image, соответственно, изменяется и набор основных свойств, например, в свойстве source можно задать путь к изображению, которое может быть расположено как в сети, так и на диске компьютера. Далее элементу BezierClock устанавливается прозрачность, а в элементе BezierCanvas заливка холста белым цветом изменяется на простую очистку куска экрана.
К сожалению, в KDE Plasma 5 так и не исправили баги под номерами #367546 и #366390, о которых я говорил в соответствующей статье. Баги недавно были помечены как решённые, но на деле абсолютно ничего не изменилось и настройки живых обоев при переключении так и не сохраняются. Поэтому сейчас у меня нет особого настроения доделывать до ума этот твик с установкой собственных фоновых изображений до ума. Конечно, было бы неплохо сделать возможность выбора фона из GUI с настройками, а не путём редактирования свойства source в файле main.qml, но пока это останется так.
Описанные изменения Bezier Clock я выложил в ветку image_test в свой репозиторий Bezier Clock на GitHub’е. Скачать TAR.XZ-пакет для пакетного менеджера Plasma Shell можно по этой ссылке:
Установить его можно с помощью команды
plasmapkg2 -t wallpaperplugin -i bezier-clock-v1.0.tar.xz, а команда
plasmapkg2 -t wallpaperplugin -r ru.exlmoto.bezierclockпроизведёт удаление пакета.
После моей публикации игры AstroSmash для Android OS на GitHub’е, со мной связался её главный разработчик Albert So и мы с ним очень мило пообщались. Он рассказал мне о создании компании Lavastorm, в которой он трудился на тот момент главным программистом, о том как лопнул «пузырь доткомов» и о том, как сложно было в 2001 году разрабатывать мобильные игры для Motorola T720 по причине нестабильной работы программной части и, к тому же, в рамках весьма аскетичных технических характеристик.
За это время накопились только незначительные улучшения игры, которые я отмечу ниже. Я решил немного оптимизировать отрисовку сенсорной стрелки на канвасе, и рисовать её только один раз при запуске игры:
diff --git a/src/ru/exlmoto/astrosmash/AstroSmashView.java b/src/ru/exlmoto/astrosmash/AstroSmashView.java index cbef5f5..aebe22a 100644 --- a/src/ru/exlmoto/astrosmash/AstroSmashView.java +++ b/src/ru/exlmoto/astrosmash/AstroSmashView.java @@ -72,6 +72,8 @@ implements SurfaceHolder.Callback, IGameWorldListener, Runnable { private Canvas bitmapCanvas = null; private Canvas globalCanvas = null; private Bitmap gameScreen = null; + private Bitmap touchArrowBitmap = null; + private Canvas touchArrowCanvas = null; private Rect touchRect = null; private Rect bitmapRect = null; @@ -163,7 +165,9 @@ implements SurfaceHolder.Callback, IGameWorldListener, Runnable { bitmapRect, screenRectPercent, painter); - drawTouchArrow(canvas, painter); + canvas.drawBitmap(touchArrowBitmap, + 0, screenRectChunkProcent, + painter); } else { canvas.drawBitmap(gameScreen, bitmapRect, @@ -181,7 +185,7 @@ implements SurfaceHolder.Callback, IGameWorldListener, Runnable { private void drawTouchArrow(Canvas canvas, Paint paint) { paint.setColor(Version.GREENCOLOR_DARK); - canvas.drawRect(0, screenRectChunkProcent, screenWidth, screenHeight, paint); + canvas.drawRect(0, 0, screenWidth, screenHeightPercent, paint); paint.setStrokeCap(Cap.ROUND); paint.setAntiAlias(true); for (int i = 0; i < 2; ++i) { @@ -335,14 +339,19 @@ implements SurfaceHolder.Callback, IGameWorldListener, Runnable { px25 = px(25); px25double = px25 * 2; - arrow_Y0 = screenHeight - touchRect.height() / 2; - arrow_Y1 = screenHeight - touchRect.height() / 4; - arrow_Y2 = screenHeight - touchRect.height() / 4 * 3; + arrow_Y0 = touchRect.height() / 2; + arrow_Y1 = touchRect.height() / 4; + arrow_Y2 = touchRect.height() / 4 * 3; arrow_X0 = screenWidth - px25; arrow_X1 = screenWidth - px25double; screenRectChunkProcent = screenHeight - screenHeightPercent; + + touchArrowBitmap = Bitmap.createBitmap(screenWidth, screenHeightPercent, Bitmap.Config.ARGB_8888); + touchArrowCanvas = new Canvas(touchArrowBitmap); + + drawTouchArrow(touchArrowCanvas, painter); } @Override
История изменений v1.1:
Известные проблемы v1.1:
Скачать готовый APK-пакет игры AstroSmash v1.1 для Android OS, можно по ссылке ниже:
[Скачать APK-пакет AstroSmash, v1.1, 111 КБ | Download AstroSmash APK-package, v1.1, 111 КB]
Все изменения и пакеты опубликованы в моём репозитории игры AstroSmash на GitHub. Ещё раз хочу выразить огромную благодарность Albert So за его отзывчивость и доброжелательность, я был очень рад получить такой фидбэк.
Наименьшее количество изменений за это время получил мой порт игры Ken’s Labyrinth на Android OS. Из самого интересного это исправление ошибки, которую я допустил по невнимательности и забывчивости:
diff --git a/src/ru/exlmoto/kenlab3d/KenLab3DTouchButtonsRects.java b/src/ru/exlmoto/kenlab3d/KenLab3DTouchButtonsRects.java index 281102a..73d2618 100644 --- a/src/ru/exlmoto/kenlab3d/KenLab3DTouchButtonsRects.java +++ b/src/ru/exlmoto/kenlab3d/KenLab3DTouchButtonsRects.java @@ -85,6 +85,8 @@ public class KenLab3DTouchButtonsRects { public void release() { m_buttonPushed = false; + m_buttonTouchId = -1; + SDLActivity.onNativeKeyUp(m_buttonCode); }
Из-за того, что я ранее не сбрасывал значение переменной m_buttonTouchId, в которой сохраняется ID пальца отпущенной сенсорной кнопки, сенсорные элементы управления работали неправильно и коряво. Например, некоторые клавиши залипали, когда на сенсорных кнопках было несколько пальцев. Почему-то на поиск способа исправления этой проблемы я потратил достаточно много времени, хотя решение лежало на самой поверхности.
История изменений v1.1:
Известные проблемы v1.1:
Скачать готовый APK-пакет игры Ken’s Labyrinth v1.1 для Android OS, можно по ссылке ниже:
Все изменения и пакеты опубликованы в моём репозитории порта Ken’s Labyrinth на GitHub. Я буду очень рад, если создатель игры Ken Silverman обратит внимание на мой небольшой труд.
Незначительные изменения затронули и мой ремейк популярного карточного пасьянса, который я назвал Snooder 21. Была исправлена опасная ошибка, опять же, допущенная по невнимательности. Из-за неё при выходе из игры в некоторых случаях был вылет на слабых и старых Android-устройствах:
diff --git a/snooder21/src/main/java/ru/exlmoto/snood21/SnoodsSurfaceView.java b/snooder21/src/main/java/ru/exlmoto/snood21/SnoodsSurfaceView.java index a477c27..36be268 100644 --- a/snooder21/src/main/java/ru/exlmoto/snood21/SnoodsSurfaceView.java +++ b/snooder21/src/main/java/ru/exlmoto/snood21/SnoodsSurfaceView.java @@ -1023,9 +1023,6 @@ public class SnoodsSurfaceView extends SurfaceView @Override public void surfaceDestroyed(SurfaceHolder holder) { SnoodsGameActivity.toDebug("Surface destroyed."); - resetGame(true); - clearColumns(); boolean shutdown = false; mIsRunning = false; while (!shutdown) { @@ -1038,6 +1035,8 @@ public class SnoodsSurfaceView extends SurfaceView SnoodsGameActivity.toDebug("Error joining to Main Thread"); } } + resetGame(true); + clearColumns(); }
Проблема решилась простым перемещением очищающих функций resetGame() и clearColumns() в конец логического блока. Изначально игровые поля сначала очищались, а потом уже совершался выход из потока, что приводило к выбросу фатального исключения из-за невозможности обращения к каким-либо элементам игровых массивов. Вылет было сложно отловить, поскольку на целых 20 запусков Snooder 21, исключение выбрасывалось лишь 1-2 раза.
История изменений v1.1:
Известные проблемы v1.1:
Скачать готовый APK-пакет игры Snooder 21 v1.1 для Android OS, можно по ссылке ниже:
[Скачать APK-пакет Snooder 21, v1.1, 421 КБ | Download Snooder 21 APK-package, v1.1, 421 KB]
Все изменения и пакеты опубликованы в моём репозитории игры Snooder 21 на GitHub. К сожалению, код моего ремейка очень ужасен и требует полного переписывания, но я никак не смог выделить достаточное количество своего свободного времени для этого. А недавно со мной связался поклонник подобных игр, использующий ник Smurf 5, который предложил мне написать онлайн-версию любимой игры, чтобы у него была возможность играть в неё на любой платформе. Меня весьма заинтересовало его предложение, так как я всегда хотел попробовать создать какое-нибудь популярное Web-приложение. Теперь думаю, какие инструменты могут помочь мне решить эту задачу, пока выбор рассматривается между Pixi.js и LibGDX. Надеюсь, при работе над этим проектом я не повторю допущенные ранее ошибки проектирования.
Больше всего изменений за это время пришлось на мой порт игры Spout для Android OS. В новой версии был полностью переписан лаунчер игры. Старое управление, сделанное с помощью монструозных и ужасных родных сенсорных элементов Android OS, было заменено на две новых реализации. Кроме того, было исправлено огромное количество ошибок, некоторые из которых приводили даже к вылетам из игры. Первый вариант сенсорного управления с помощью полупрозрачных кнопок был реализован по тому же принципу, что и в моём порте Ken’s Labyrinth:
Освещать подробности его работы я не считаю необходимым. А вот второй вариант, с помощью сенсорного колеса-джойстика, несколько интереснее и удобнее, поэтому в игре он активирован по-умолчанию.
Его реализацию я подсмотрел в исходных кодах программиста, который использует никнейм [SoD]Thor. Я смог интегрировать такой джойстик в свою игру. Более подробное воплощение подобного варианта управления уже в коде, я опишу в своей следующей статье, посвящённой портированию игры Adamant Armor Affection Adventure на Android OS.
Из-за невнимательного чтения документации я забывал удалять ссылки на JNI-объекты после их использования, поэтому при создании огромного числа ссылок (например, при частых вызовах кода вибрации) я получал примерно такую ошибку:
dalvikvm(11625): Failed adding to JNI local ref table (has 512 entries)
И последующий вылет из игры. Проблема решилась добавлением вызова функции DeleteLocalRef() для утилизации созданных ссылок:
diff --git a/jni/Spout/src/piece.c b/jni/Spout/src/piece.c index 887217b..fcc52d0 100644 --- a/jni/Spout/src/piece.c +++ b/jni/Spout/src/piece.c @@ -813,6 +813,8 @@ void pceFileWriteSct (const void *ptr, /*int sct,*/ int len) // Call JAVA-method (*javaEnviron)->CallStaticVoidMethod(javaEnviron, clazz, methodId, hiScore[1], hiScore[0]); + + (*javaEnviron)->DeleteLocalRef(javaEnviron, clazz); } } #endif // !ANDROID NDK @@ -887,6 +889,7 @@ void vibrateFromJNI(int duration) { // Call JAVA-method (*javaEnviron)->CallStaticVoidMethod(javaEnviron, clazz, methodId, duration); + (*javaEnviron)->DeleteLocalRef(javaEnviron, clazz); } } } @@ -910,6 +913,8 @@ void playGameOverSoundFromJNI() { jint soundID = (*javaEnviron)->GetStaticIntField(javaEnviron, clazz, fieldID); LOGI("JNI: soundID is: %d", (int)soundID); + (*javaEnviron)->DeleteLocalRef(javaEnviron, clazz); + // Now return to SpoutActivity clazz = (*javaEnviron)->FindClass(javaEnviron, "ru/exlmoto/spout/SpoutActivity"); if (clazz == 0) { @@ -926,6 +931,8 @@ void playGameOverSoundFromJNI() { // Call JAVA-method (*javaEnviron)->CallStaticVoidMethod(javaEnviron, clazz, methodId, soundID); } + + (*javaEnviron)->DeleteLocalRef(javaEnviron, clazz); } } }
Для того, чтобы лаунчер не закрывался после вызова статического метода System.exit(), мне пришлось использовать вот такую подпорку:
diff --git a/src/ru/exlmoto/spout/SpoutActivity.java b/src/ru/exlmoto/spout/SpoutActivity.java index 4b2f749..aa624bc 100644 --- a/src/ru/exlmoto/spout/SpoutActivity.java +++ b/src/ru/exlmoto/spout/SpoutActivity.java @@ -28,6 +28,7 @@ import java.io.IOException; import android.app.Activity; import android.content.Context; +import android.content.Intent; import android.content.SharedPreferences; import android.hardware.Sensor; import android.hardware.SensorEvent; @@ -239,6 +240,12 @@ public class SpoutActivity extends Activity implements SensorEventListener { } } + private void startLauncher() { + Intent intent = this.getPackageManager() + .getLaunchIntentForPackage("ru.exlmoto.spout"); + startActivity(intent); + } + @Override protected void onDestroy() { toDebug("Destroying activity..."); @@ -251,13 +258,15 @@ public class SpoutActivity extends Activity implements SensorEventListener { public void onBackPressed() { toDebug("Back key pressed!, Exiting..."); - m_spoutNativeSurface.onPause(); - m_spoutNativeSurface.onClose(); - if (SpoutSettings.s_Sound) { soundPool.release(); } + startLauncher(); + + m_spoutNativeSurface.onPause(); + m_spoutNativeSurface.onClose(); + // Because we want drop all memory of library // System.exit(0); // Now exit() call in native deinitSpoutGLES() diff --git a/src/ru/exlmoto/spout/SpoutLauncher.java b/src/ru/exlmoto/spout/SpoutLauncher.java index 9f66c52..0c85395 100644 --- a/src/ru/exlmoto/spout/SpoutLauncher.java +++ b/src/ru/exlmoto/spout/SpoutLauncher.java @@ -498,6 +498,7 @@ public class SpoutLauncher extends Activity { if (testOffsets(SpoutSettings.s_OffsetX, SpoutSettings.s_OffsetY, displayW, displayH)) { Intent intent = new Intent(v.getContext(), SpoutActivity.class); startActivity(intent); + finish(); } else { showDialog(OFFSET_ERROR); }
Вызов System.exit() (или exit(), если речь идёт о коде на языках C/C++) необходим, поскольку без него после выхода и повторного входа в игру появляются различные баги из-за того, что нативная библиотека не может заново нормально переинициализироваться.
История изменений v1.1:
Известные проблемы v1.1:
Скачать готовый APK-пакет игры Spout v1.1 для Android OS, можно по ссылке ниже:
[Скачать APK-пакет Spout, v1.1, 207 КБ | Download Spout APK-package, v1.1, 207 KB]
Все изменения и пакеты опубликованы в моём репозитории порта игры Spout на GitHub. Кстати, в лаунчер игры было добавлено небольшое изображение-обложка, в соответствии с остальными моими проектами для Android OS. Теперь Spout совершенно не выбивается из ряда моих других приложений.
Итак, в прошедшие четыре месяца я занимался в основном обновлением уже существующих проектов или созданием небольших программ по просьбам моих друзей. Все мои наработки вы можете увидеть в соответствующих репозиториях. Скачать готовые пакеты можно либо по ссылкам в этой статье, либо на новой странице блога, которая посвящена значимым и актуальным проектам. Кроме того, некоторые пакеты опубликованы в релизах самих репозиториев.
Надеюсь, кому-нибудь описанный здесь мой опыт решения различных проблем и ошибок когда-нибудь сможет пригодиться.
]]>Делать собственную тему с нуля для блога на движке WordPress, не имея опыта работы с ним, достаточно накладно и сложно. Поэтому было решено пойти по самому простому пути. Я попробовал найти какой-нибудь красивый и популярный WordPress-шаблон и изменив вёрстку, дизайн и функциональность, превратить его в эксклюзивную тему для моего ресурса. Я достаточно долго перебирал огромное количество кастомных шаблонов, но так и не находил ничего из того, что мне бы сразу безоговорочно понравилось. На самом деле то, что я искал, лежало всегда на виду. Я слишком поздно заглянул в стандартные темы WordPress’а, решив что там не будет ничего интересного. И очень даже зря, стоковый шаблон Twenty Fourteen выглядел весьма симпатично. На его основе я и решил создать тему Moto Juice, немного упростив вёрстку и добавив требуемые возможности. Эта тема отлично смотрелась и работала не только на настольных компьютерах, но и на мобильных устройствах.
Если вы не впервые попали на мой ресурс, то наверное помните старую и ужасную тему сайта. Она была создана в далёком 2010 году за несколько минут в проприетарной программе Artisteer из комбинации нескольких похожих друг на друга случайных блоков. Я назвал её Moto Orange, поскольку она получилась у меня в ярких оранжевых тонах. Затем я просто набрал в популярном графическом редакторе каким-то светлым шрифтом с тенью заголовок, вставил его в шапку и установил этот шаблон в качестве дефолтной темы WordPress. Поддерживать Moto Orange было тем ещё «удовольствием», поскольку весь код темы был автоматически сгенерирован программой Artisteer. Ещё это сильно влияло и на безопасность, так как аудит сгенерированного кода никто никогда не проводил.
Мой блог переехал на WordPress достаточно давно, когда ещё не было такого активного развития мобильных устройств и планшетов, поэтому тема Moto Orange получилась абсолютно неприспособленной к современному миру. А потому поисковики, например, Google, понижали сайт в своей выдаче результатов поиска. Стало ясно, что мне требуется тема с адаптивной вёрсткой. К счастью, в шаблоне Twenty Fourteen, который я выбрал в качестве отправной точки и базы, эта функциональность досталась мне в наследство.
Тема Twenty Fourteen использует такие современные технологии, как библиотеку jQuery и иконки из векторного шрифта Genericons. Специалист по Web-технологиям и дизайнер из меня довольно плохой, знание языков программирования PHP, JavaScript и языка описания внешнего вида CSS у меня на начальном уровне, поэтому, если какое-либо решение вызывает у вас удивление и негодование, прошу меня простить, это мой первый опыт использования современных Web-технологий в действии.
1. Конфигурация Web-сервера Nginx для WordPress
2. Отключение избыточной функциональности
3. Главное меню и проблемы с боковой панелью
4. Классный логотип в шапку блога
5. Виджет с моими случайными проектами
6. Виджет переключения цветовых скинов
7. Некоторые дополнительные улучшения темы
8. Локализация темы WordPress
9. Заключение, полезные советы, ссылки и информация
Движок WordPress достаточно легко установить на Web-сервер Nginx, в интернете полно материалов, посвящённых процессу установки. Однако, очень многие системные администраторы забывают правильно отредактировать дефолтный конфигурационный файл Nginx’а. Из-за чего на многих WordPress-сайтах, запущенных на этом Web-сервере, возникает очень интересный баг: при использовании внутреннего поиска невозможно перейти на вторую страницу выдачи результатов. Вместо перехода просто перебрасывает на вторую страницу главной, где содержится общая лента постов.
Оказывается, нужно просто немного изменить дефолтный конфигурационный файл Nginx, добавив конструкцию ?$args сразу после index.php в секции try_files раздела location. Это должно выглядеть приблизительно вот так:
server { location / { try_files $uri $uri/ /index.php?$args; } ... }
На исправление этой досадной ошибки я потратил достаточно много времени и нервов. В начале я винил свой новоиспечённый шаблон, думал, что каким-то магическим образом нарушил пагинацию страниц в нём, но, как оказалось, на старой теме тоже присутствует этот баг. Я даже попробовал переключиться на стандартные шаблоны и баг сохранился. А это могло означать только одно: причина неправильного поведения была гораздо глубже и заключалась в Web-сервере. Спустя несколько минут поиска я наткнулся на решение проблемы на ресурсе wordpress.stackexchange.com, которое выложил пользователь, использующий ник MultiformeIngegno, огромное ему спасибо за это.
Так что, если у вас в WordPress, развёрнутом на Nginx, не работает переход на вторую страницу результатов поиска, то данная информация поможет вам решить эту проблему и сэкономить время.
В самом начале создания собственной темы на основе шаблона Twenty Fourteen, я решил отключить избыточную для моего блога функциональность. Мне очень не нравится, когда на сайтах при чтении текста во время прокрутки страницы по ней перемещаются различные интерфейсные элементы, вроде кнопок или шапки. Они мешают читать и отвлекают на себя внимание. Поэтому первым делом я отключил «плавающий» заголовок в главном стиле темы, в файле style.css:
diff --git a/style.css b/style.css index 4221371..05cebab 100644 --- a/style.css +++ b/style.css @@ -3453,12 +3453,11 @@ a.post-thumbnail:hover { /* Fixed Header */ .masthead-fixed .site-header { - position: fixed; - top: 0; + position: relative; } .admin-bar.masthead-fixed .site-header { - top: 32px; + top: 0px; } .masthead-fixed .site-main {
Кроме того, на старой теме я вообще не пользовался тегами, мне было достаточно различных категорий. Поэтому теги были использованы мной для ключевых поисковых фраз, что выглядело не очень прилично. Я решил вообще отключить вывод тегов, раз так и не научился их правильно готовить. Для этого я удалил из всех релевантных PHP-файлов строку:
<?php the_tags( '<footer class="entry-meta"><span class="tag-links">', '', '</span></footer>' ); ?>
Помимо этих небольших преобразований я отцентрировал контент страницы по середине окна, увеличил размер сайта до 1366 пикселей по ширине, уменьшил всевозможные отступы, удалил нестандартный шрифт Lato, немного переделал подвал сайта, изменил базовые цвета темы и сделал ещё много мелких изменений, которые можно будет посмотреть в истории коммитов репозитория (ссылка на него будет ниже).
Стандартное главное меню, которое можно вызывать нажав на кнопку в шапке мобильной версии сайта, в теме Twenty Fourteen было недостаточно функциональным. Оно было практически бесполезным, поскольку отображало лишь несколько страничек, которые были опубликованы для всех посетителей блога.
Я решил немного увеличить его функциональность и перенести туда содержимое боковой панели. Для этого я отредактировал файл header.php следующим образом:
diff --git a/header.php b/header.php index d21a652..e501a4a 100644 --- a/header.php +++ b/header.php @@ -93,7 +93,10 @@ <nav id="primary-navigation" class="site-navigation primary-navigation" role="navigation"> <button class="menu-toggle"><?php _e( 'Primary Menu', 'twentyfourteen' ); ?></button> <a class="screen-reader-text skip-link" href="#content"><?php _e( 'Skip to content', 'twentyfourteen' ); ?></a> - <?php wp_nav_menu( array( 'theme_location' => 'primary', 'menu_class' => 'nav-menu', 'menu_id' => 'primary-menu' ) ); ?> + <div id="primary-menu" class="nav-menu" aria-expanded="true"> + <?php dynamic_sidebar( 'sidebar-1' ); ?> + </div> </nav> </div>
Потом немного поправил главный стиль темы так, чтобы этим меню было удобно пользоваться на мобильных устройствах и планшетах. Получилось, на мой взгляд, весьма неплохо и функционально:
Главный плюс такого подхода в том, что все виджеты WordPress’а, которые я добавлю на боковую панель, будут отображаться и в меню сайта мобильной версии. Например, я добавил туда строку поиска и сортировку постов по рубрикам. Функциональность панели полностью повторяет главное меню, это отличное решение для навигации по страницам моего блога.
Кроме того, я поправил ещё одно досадное недоразумение в теме Twenty Fourteen. Почему-то в этом шаблоне изначально был очень странный порядок загрузки блоков сайта: в начале загружалась шапка, потом контент страницы, потом боковая панель и, наконец, подвал. Если страница содержала много текста и долго загружалась, то боковая панель прогружалась с огромной задержкой, что откровенно раздражало, так как она появлялась внезапно и отвлекала на себя внимание. Я исправил порядок загрузки таким образом, чтобы боковая панель грузилась сразу после шапки. Все мои изменения были произведены в тех PHP-файлах, где встречалась следующая конструкция:
diff --git a/404.php b/404.php index 7f5bef8..c23b707 100644 --- a/404.php +++ b/404.php @@ -7,7 +7,8 @@ * @since Twenty Fourteen 1.0 */ -get_header(); ?> +get_header(); +get_sidebar(); ?> <div id="primary" class="content-area"> @@ -28,5 +29,4 @@ get_header(); ?> <?php get_sidebar( 'content' ); -get_sidebar(); get_footer();
Ещё я немного отредактировал стиль и незначительно поправил файл js/functions.js с различными вспомогательными функциями, например, в нём была обработка нажатия кнопки в шапке сайта. По моему скромному мнению, именно такой порядок загрузки и должен был быть в шаблоне сразу. Я до сих пор не понимаю зачем и с какой целью разработчикам темы понадобилось его изменять. После моих исправлений раздражающий эффект запоздалого отображения боковой панели полностью исчез.
Как уже было сказано выше, для старой темы Moto Orange я просто набрал название ресурса незамысловатым шрифтом с тенью и добавил его изображением в шапку. Для новой темы Moto Juice я решил сделать красивый логотип в стиле Graffiti, поскольку этот стиль мне очень нравится. К сожалению, художник из меня совсем плохой и всё что я пытался нарисовать выглядело весьма неказисто и невзрачно. Я хотел изобразить надпись в таком вот стиле:
Но у меня ничего не получалось. Тогда я решил доверить это дело профессионалам и обратился к своему другу Zorge.R‘у за помощью. Мы с ним целых полчаса выбирали какой-нибудь похожий и интересный шрифт в Graffiti-стиле на замечательном ресурсе dafont.com. В конце концов остановились на двух шрифтах: Docallisme On Street и Next Ups. Увы, но первый шрифт оказался собран с какой-то ошибкой и не смог нормально отрендерится в Photoshop’е у Zorge.R’а. Зато второй шрифт отлично отобразился и я решил использовать именно его.
Zorge.R набрал этим шрифтом название моего блога, преобразовал текст в объёмный 3D-слой, добавил обводки и градиент на гранях глифов. Затем немного сбил плотность букв в логотипе, чтобы он смотрелся более компактно и контрастно. В завершении дополнил надпись несколькими штрихами круглой красной кисти, как в это было сделано на оригинальном изображении выше.
Получилось просто здорово, огромное спасибо Zorge.R’у за работу! Логотип не только отлично вписался в шапку сайта, он ещё идеально подошёл ко всем цветовым скинам, которые я реализовал немного позже. Ещё отдельная благодарность создателю шрифта Next Ups, который использует ник imagex, за то, что поделился результатами своего творчества.
Изначально шапка в шаблоне Twenty Fourteen выглядела довольно узкой, поэтому я решил сделать её немного шире и добавить в появившееся пространство какую-нибудь полезную информацию. В качестве источника этой информации я решил выбрать несколько моих актуальных проектов для различных платформ, над которыми я работал в последнее время. Поскольку этих проектов было больше пяти, а место в шапке не было бесконечным, я решил выводить только пять случайных пунктов из всего списка. При наведении курсора мышки на иконку, сбоку слева отображается небольшое описание проекта. В целом получилось достаточно симпатично:
Для этого виджета я создал специальный PHP-файл headerwidget.php, в котором написал код случайного выбора пяти уникальных элементов из общего массива. Этот файл выглядит следующим образом:
<div class="headerWidget" id="headerWidgetId"> <?php $imagesCount = 5; $themeDir = get_stylesheet_directory_uri(); $imageLinks = array( "<a href=\"//exlmoto.ru/spout-droid/\" title=\"Spout\">" . "<img class=\"imgElem\" id=\"imgElem0\" src=\"" . $themeDir . "/images/widget/0.png\" />" . "</a>", "<a href=\"//exlmoto.ru/kenlab3d-droid/\" title=\"Ken's Labyrinth\">" . "<img class=\"imgElem\" id=\"imgElem1\" src=\"" . $themeDir . "/images/widget/1.png\" />" . "</a>", "<a href=\"//exlmoto.ru/astrosmash-droid/\" title=\"AstroSmash\">" . "<img class=\"imgElem\" id=\"imgElem2\" src=\"" . $themeDir . "/images/widget/2.png\" />" . "</a>", "<a href=\"//exlmoto.ru/snooder21-droid/\" title=\"Snooder 21\">" . "<img class=\"imgElem\" id=\"imgElem3\" src=\"" . $themeDir . "/images/widget/3.png\" />" . "</a>", "<a href=\"//exlmoto.ru/bezier-clock/\" title=\"Bezier Clock\">" . "<img class=\"imgElem\" id=\"imgElem4\" src=\"" . $themeDir . "/images/widget/4.png\" />" . "</a>", "<a href=\"//exlmoto.ru/nxengine/\" title=\"Cave Story\">" . "<img class=\"imgElem\" id=\"imgElem5\" src=\"" . $themeDir . "/images/widget/5.png\" />" . "</a>", "<a href=\"//exlmoto.ru/writing-telegram-bots/\" title=\"Gadget Hackwrench\">" . "<img class=\"imgElem\" id=\"imgElem6\" src=\"" . $themeDir . "/images/widget/6.png\" />" . "</a>" ); $lengthArr = count($imageLinks); $randNum = rand(0, $lengthArr - 1); $offset = ($randNum + $imagesCount) - $lengthArr; if ($randNum <= $lengthArr - $imagesCount) { for ($i = $randNum; $i < $randNum + $imagesCount; $i++) { echo $imageLinks[$i]; } } else { for ($j = $randNum; $j < $lengthArr; $j++) { echo $imageLinks[$j]; } for ($k = 0; $k < $offset; $k++) { echo $imageLinks[$k]; } } ?> <?php /* * Please use the PoEdit program for changing the descriptions. Don't edit this labels manually! * PoEdit site: https://poedit.net/ */ ?> <div class="imgDescription" id="imgDesc0"><?php printf( __( '<b>Project 1</b> Description.', 'moto-juice' ) ); ?></div> <div class="imgDescription" id="imgDesc1"><?php printf( __( '<b>Project 2</b> Description.', 'moto-juice' ) ); ?></div> <div class="imgDescription" id="imgDesc2"><?php printf( __( '<b>Project 3</b> Description.', 'moto-juice' ) ); ?></div> <div class="imgDescription" id="imgDesc3"><?php printf( __( '<b>Project 4</b> Description.', 'moto-juice' ) ); ?></div> <div class="imgDescription" id="imgDesc4"><?php printf( __( '<b>Project 5</b> Description.', 'moto-juice' ) ); ?></div> <div class="imgDescription" id="imgDesc5"><?php printf( __( '<b>Project 6</b> Description.', 'moto-juice' ) ); ?></div> <div class="imgDescription" id="imgDesc6"><?php printf( __( '<b>Project 7</b> Description.', 'moto-juice' ) ); ?></div> <div class="imgDescription" id="imgDesc7"><?php printf( __( '<b>Project 8</b> Description.', 'moto-juice' ) ); ?></div> <div class="imgDescription" id="imgDesc8"><?php printf( __( '<b>Project 9</b> Description.', 'moto-juice' ) ); ?></div> <div class="imgDescription" id="imgDesc9"><?php printf( __( '<b>Project 10</b> Description.', 'moto-juice' ) ); ?></div> <div class="imgDescription" id="imgDesc10"><?php printf( __( '<b>Project 11</b> Description.', 'moto-juice' ) ); ?></div> <div class="imgDescription" id="imgDesc11"><?php printf( __( '<b>Project 12</b> Description.', 'moto-juice' ) ); ?></div> <div class="imgDescription" id="imgDesc12"><?php printf( __( '<b>Project 13</b> Description.', 'moto-juice' ) ); ?></div> <div class="imgDescription" id="imgDesc13"><?php printf( __( '<b>Project 14</b> Description.', 'moto-juice' ) ); ?></div> <div class="imgDescription" id="imgDesc14"><?php printf( __( '<b>Project 15</b> Description.', 'moto-juice' ) ); ?></div> <div class="imgDescription" id="imgDesc15"><?php printf( __( '<b>Project 16</b> Description.', 'moto-juice' ) ); ?></div> </div>
В массиве imageLinks в HTML-разметке хранится список всех ссылок на проекты, вместе с их иконками. Алгоритм получения массива из пяти случайных и уникальных элементов тривиален. В начале выбирается случайное число-указатель, которое определяет индекс элемента в общем массиве, который будет нашим первым элементом и точкой отсчёта. Затем отбираются последующие четыре элемента, которые находятся правее. Если происходит вылет за границы первоначального массива, недостающие элементы добираются с его конца. Далее располагаются шестнадцать уже подготовленных строк-заглушек. Эти строки переводятся в индивидуальное описание каждого проекта благодаря встроенной системе перевода в WordPress. Когда все строки закончатся, я просто добавлю новые и обновлю файлы переводов.
Для того, чтобы всё это работало, получившийся файл был включён в header.php и в него же были добавлены два блока. Первый, mainDescription, используется для описания проекта, а второй, secondDescription, используется для приветствия, которое постоянно отображается в шапке ресурса:
<?php include_once("headerwidget.php") ?> <div class="mainDescription" id="imgDescMain"></div> <div class="secondDescription" id="imgDescSecond"><?php printf( __( 'Welcome!', 'moto-juice' ) ); ?></div>
Для того, чтобы описание проектов менялось в зависимости от того, на какую иконку был наведён курсор мышки, я использовал магию JavaScript и добавил в файл js/functions.js следующее:
// Show or Hide Image Widget description // substring(7) because imgDescX jQuery('.imgElem').mouseenter(function() { jQuery('.mainDescription').html(jQuery('#imgDesc' + $(this).attr('id').substring(7)).html()); jQuery('.mainDescription').show(); }); jQuery('.imgElem').mouseleave(function() { jQuery('.mainDescription').hide(); });
При наведении курсора на иконку берётся её идентификационный номер и вызывается функция отображения нужного текстового описания. После того, как курсор покинет область иконки, описание скрывается. Стиль для виджета в файле style.css используется следующий:
/* Header Widget Settings */ .headerWidget { position: absolute; right: 64px; top: 58px; } .imgElem { float: right; margin-left: 5px; border: none; } .imgElem:hover { outline: 1px solid white; } .imgDescription { display: none; } .mainDescription, .secondDescription { position: absolute; top: 58px; font-size: 10px; color: #F5F5F5; } .mainDescription { display: none; left: 280px; width: 470px; } .mainDescription b { color: #EEE8AA; font-weight: bold; } .secondDescription { left: 30px; width: 240px; } .widgetBreaker { position: absolute; top: 48px; width: 100%; margin: 0 0 0 0; background-color: black; } .widget ul { font-size: 1.1em; }
Как видно выше, все объекты класса mainDescription по умолчанию скрыты и отображаются лишь тогда, когда на них будет наведён курсор мышки. Иконка проекта подсвечивается с помощью рамки, которая задаётся свойством outline и не изменяет размеров блока.
Таким образом, чтобы добавить дополнительный проект в этот виджет, мне потребуется произвести всего три простейших действия:
Виджет с проектами отображается только в настольной версии сайта, потому что в мобильной и планшетной версии он бесполезен и лишь занимает полезное пространство на небольшом экране.
Идея кастомизации блога с помощью цветовых схем мне пришла в голову после того, как я начал активно изменять цвета в главном CSS-стиле шаблона. Я хотел выполнить дизайн блога в нейтральных и спокойных тонах, но некоторые читатели привыкли к ярким и сочным оттенкам. Почему бы не угодить и тем, и другим, сделав аккуратный виджет и механизм переключения CSS-стилей? Я задумался над реализацией и размещением такого виджета и получилось следующее:
Виджет я разместил внизу блога, на правой стороне подвала. Он представляет из себя восемь небольших цветных квадратов, при нажатии на которые будет перегружена страница и выбрана соответствующая цветовая схема.
Работает это следующим образом: в PHP-файл footer.php, который отвечает за подвал сайта, был добавлен следующий блок кода:
<div class="skin-selector"> <table class="skin-table" cellspacing="5" cellpadding="0"> <tr> <td><div name="Skin0" class="skinClass" title="<?php printf( __( 'Light Skin', 'moto-juice' ) ); ?>" id="skinDefault"></div></td> <td><div name="Skin1" class="skinClass" title="<?php printf( __( 'Gray Skin', 'moto-juice' ) ); ?>" id="skinOgre"></div></td> </tr> <tr> <td><div name="Skin2" class="skinClass" title="<?php printf( __( 'Green Skin', 'moto-juice' ) ); ?>" id="skinGreen"></div></td> <td><div name="Skin3" class="skinClass" title="<?php printf( __( 'Orange Skin', 'moto-juice' ) ); ?>" id="skinOrange"></div></td> </tr> <tr> <td><div name="Skin4" class="skinClass" title="<?php printf( __( 'Blue Skin', 'moto-juice' ) ); ?>" id="skinBlue"></div></td> <td><div name="Skin5" class="skinClass" title="<?php printf( __( 'Yellow Skin', 'moto-juice' ) ); ?>" id="skinYellow"></div></td> </tr> <tr> <td><div name="Skin6" class="skinClass" title="<?php printf( __( 'Solarized Skin', 'moto-juice' ) ); ?>" id="skinSolarized"></div></td> <td><div name="Skin7" class="skinClass" title="<?php printf( __( 'Dark Skin', 'moto-juice' ) ); ?>" id="skinDark"></div></td> </tr> </table> </div>
Это табличная вёрстка, благодаря которой и создаётся таблица из нужного количества квадратов. Чтобы эти квадраты были разноцветными, в файл требуемого стиля скина добавлены следующие фрагменты:
/* Skin Widget */ .skin-table { margin-bottom: 0px; border-spacing: 0px; } .skin-table, .skin-table tr, .skin-table td, .skin-table th { border: none; } .skin-selector { position: relative; display: inline-block; float: right; width: 35px; margin-top: 4px; margin-right: 2px; z-index: 9; } .skinClass { height: 10px; width: 10px; border: 1px solid black; margin-bottom: 2px; } #skinDefault { background-color: #6699CC; border: 1px solid white; } #skinOgre { background-color: #B5B8B1; border: 1px solid black; } #skinGreen { background-color: #4CBB17; border: 1px solid black; } #skinOrange { background-color: #FF7E00; border: 1px solid black; } #skinBlue { background-color: #B0C4DE; border: 1px solid black; } #skinYellow { background-color: #FFFF00; border: 1px solid black; } #skinSolarized { background-color: #C1B58D; border: 1px solid black; } #skinDark { background-color: #696969; border: 1px solid black; }
Задействованная цветовая схема выделена белой рамкой. Теперь снова переходим к магии JavaScript, чтобы закрепить действия за нажатием на эти цветные квадратики. Для этого я добавил в файл js/functions.js несколько функций и изменений:
function setCookie(key, value) { var expires = new Date(); expires.setTime(expires.getTime() + (365 * 24 * 60 * 60 * 1000)); document.cookie = key + '=' + value + ';path=/' + ';expires=' + expires.toUTCString(); } ... ( function( $ ) { // Switch Skins jQuery('.skinClass').click(function() { var i = $(this).attr('name').slice(-1); setCookie('skin', i); location.reload(); }); ... }
В принципе, здесь абсолютно нет ничего сложного: каждому цветному квадрату добавляется собственный обработчик нажатия, который устанавливает идентифицирующую переменную скина skin в хранилище Cookies с помощью функции setCookie(), а потом просто перегружает страницу.
Далее в действие вступает PHP-файл colorize.php, который включается в header.php и выглядит следующим образом:
<?php switch (intval($_COOKIE["skin"])) { default: case 0: break; case 1: echo "<link rel=\"stylesheet\" type=\"text/css\" href=\"" . get_stylesheet_directory_uri() . "/style_gray.css\" title=\"gray\" />\n"; break; case 2: echo "<link rel=\"stylesheet\" type=\"text/css\" href=\"" . get_stylesheet_directory_uri() . "/style_green.css\" title=\"green\" />\n"; break; case 3: echo "<link rel=\"stylesheet\" type=\"text/css\" href=\"" . get_stylesheet_directory_uri() . "/style_orange.css\" title=\"orange\" />\n"; break; case 4: echo "<link rel=\"stylesheet\" type=\"text/css\" href=\"" . get_stylesheet_directory_uri() . "/style_blue.css\" title=\"blue\" />\n"; break; case 5: echo "<link rel=\"stylesheet\" type=\"text/css\" href=\"" . get_stylesheet_directory_uri() . "/style_yellow.css\" title=\"yellow\" />\n"; break; case 6: echo "<link rel=\"stylesheet\" type=\"text/css\" href=\"" . get_stylesheet_directory_uri() . "/style_solarized.css\" title=\"solarized\" />\n"; break; case 7: echo "<link rel=\"stylesheet\" type=\"text/css\" href=\"" . get_stylesheet_directory_uri() . "/style_dark.css\" title=\"dark\" />\n"; break; } ?>
Здесь с помощью языка программирования PHP мы читаем ранее сохранённую в Cookie переменную skin и в соответствии с её значением подгружаем требуемый стиль скина из отдельных CSS-файлов. Эти файлы представляют собой копии главного стиля темы, но с изменёнными цветами и незначительными исправлениями.
Благодаря этому механизму удалось сделать целых восемь различных цветных скинов и читатель моего блога теперь может настроить внешний вид сайта в соответствии со своим вкусом и предпочтениями. В некоторых скинах, например, в тёмном, прописано изменение не только базовых элементов ресурса, но и цветовой подсветки кода. Это достигается просто расширением CSS-стиля с добавлением в него подходящей темы WordPress-плагина Crayon, который отвечает за подсветку кода.
В качестве цветовой схемы сайта по умолчанию был установлен наиболее нейтральный скин в классических тонах, к которым все уже давно привыкли и которые меньше всего вызывают раздражение: оттенки синего, серый, белый, жёлтый и бежевый цвета.
Ссылки на рубрики в боковой панели блога было решено сделать блочными, так их гораздо удобнее нажимать и в мобильной версии сайта, и в настольной. К сожалению, дело не обошлось просто правкой CSS, так как эти ссылки хитро создавались самим виджетом. Я решил не разбираться как они генерируются в дебрях движка WordPress, ведь все мои изменения после очередного обновления были бы отменены. Поэтому пришлось патчить их «на лету» с помощью JavaScript, для этого в файл js/functions.js я добавил следующее:
// Hack for Categories Widget if (jQuery('.cat-item')) { for (var i = 0; i < jQuery('.cat-item').length; ++i) { var s = jQuery('.cat-item')[i].innerHTML; s = s.replace('</a>', '').trim() + '</a>'; jQuery('.cat-item')[i].innerHTML = s; } } jQuery('.widget a').css({ display: "block" });
Справа от ссылки написано число статей в рубрике и эта цифра является обычным текстом, а не её частью. Этим фрагментом кода я вношу число в ссылку, а закрывающий тег выношу за цифру. Затем применяю CSS-свойство block к общему классу ссылок в виджете. Цвет ссылок и цвет текста в боковой панели с помощью CSS сделан одинаковым, поэтому в момент выполнения Js-кода нет резкого перехода и на странице ничего не мелькает.
Ещё одно улучшение, которое я добавил в тему — прокрутка страницы на самый верх при нажатии на пустое место в боковой панели:
// Click to the sidebar to top of page jQuery("#sec-block").click( function() { $("html, body").animate({ scrollTop: 0 }, "slow"); });
Практически все мои последние посты в блоге весьма объёмные и это нововведение значительно упрощает навигацию. Теперь, чтобы перейти в самое начало статьи, не нужно тянуться к клавиатуре или долго прокручивать страницу колесом мышки.
Ещё одно улучшение предложил мой друг yakimka, посоветовав добавить возможность полного скрытия боковой панели, чтобы ничего не отвлекало от чтения текста, как это было сделано в мобильной и планшетной версиях сайта. С помощью CSS и JavaScript я легко реализовал эту функциональность:
function hideSideBar() { $('#secondary').hide(); $('#sec-block').hide(); $('#content').css('margin-left', '0px'); } function showSideBar() { $('#secondary').show(); $('#sec-block').show(); $('#content').css('margin-left', '130px'); } // Enable menu toggle for small screens. ( function() { if ( ! nav.length || ! button.length ) { return; } // Hide button if menu is missing or empty. if ( ! menu.length || ! menu.children().length ) { button.hide(); return; } button.on( 'click.twentyfourteen', function() { var width = Math.max( $(window).width(), window.innerWidth); if (width < 1008) { isPushedMenu = !isPushedMenu; nav.toggleClass( 'toggled-on' ); if ( nav.hasClass( 'toggled-on' ) ) { $( this ).attr( 'aria-expanded', 'true' ); menu.attr( 'aria-expanded', 'true' ); } else { $( this ).attr( 'aria-expanded', 'false' ); menu.attr( 'aria-expanded', 'false' ); } } else { isPushedSideBar = !isPushedSideBar; if (isPushedSideBar) { hideSideBar(); } else { showSideBar(); } } } ); } )(); function onResizeARIA() { // http://stackoverflow.com/a/8501499/2467443 var width = Math.max( $(window).width(), window.innerWidth); // Hack for disable main description widget if ( width < 1218 ) { jQuery('.mainDescription').hide(); } if ( width < 1008 ) { button.attr( 'aria-expanded', 'false' ); menu.attr( 'aria-expanded', 'false' ); hideSideBar(); isPushedSideBar = true; //button.attr( 'aria-controls', 'primary-menu2' ); } else { if (isPushedMenu) { nav.toggleClass( 'toggled-on' ); // console.log('isPushedMenu!') isPushedMenu = false; } if (isPushedSideBar) { showSideBar(); isPushedSideBar = false; } button.removeAttr( 'aria-expanded' ); menu.removeAttr( 'aria-expanded' ); //button.removeAttr( 'aria-controls' ); } } _window .on( 'load.twentyfourteen', onResizeARIA ) .on( 'resize.twentyfourteen', function() { onResizeARIA(); } );
Функции hideSideBar() и showSideBar(), как видно из их названия, скрывают и показывают боковую панель. Они вызываются в изначально написанном обработчике button.on(), который отвечает за нажатие на кнопку в шапке сайта. Функция onResizeARIA() реагирует на изменение размеров окна и убирает панель, когда ширина сайта уменьшается до 1007 пикселей и ниже. Далее я меняю иконку в кнопке с помощью CSS-правила @media и CSS-свойства content. Иконка меняется после того, как ширина страницы становится равной 1008 пикселям:
.menu-toggle:before { color: #ffffff; content: "\f419"; padding: 16px; } @media screen and (min-width: 1008px) { .primary-navigation .menu-toggle::before { content: "\f453"; } ... }
В качестве иконки для кнопки используется юникодный символ, который указывает на соответствующую пиктограмму в наборе Genericons.
Мне никогда не нравилась стандартная форма отправки комментариев WordPress, которая была задействована на старой теме Moto Orange. Эта форма была плохо свёрстана, имела огромные поля ввода и выглядела следующим образом:
Я решил немного её переделать, в частности, слить все три линии ввода персональных данных в один блок, уменьшить его ширину и перенести выше поля ввода комментария. Для этого я отредактировал стандартный PHP-файл шаблона function.php, в котором располагаются основные функции темы, добавив туда специальный фильтр:
function move_comment_field( $fields ) { $comment_field = $fields['comment']; unset( $fields['comment'] ); $fields['comment'] = $comment_field; return $fields; } add_filter( 'comment_form_fields', 'move_comment_field' );
В главный CSS-стиль темы было добавлено следующее:
/* Comments */ .comment-form-author, .comment-form-email, .comment-form-url { display: inline-block; }
После внесёния этих изменений, форма стала выглядеть более привычно и логично:
Кроме формы комментариев я подверг изменениям и стандартную форму поиска, которая была использована в теме Twenty Fourteen. По какой-то причине в ней отсутствовала кнопка. Я создал PHP-файл searchform.php и наполнил его следующим содержимым:
<form method="get" name="searchform" action="<?php echo esc_url( home_url( '/' ) ); ?>"> <div class="search"> <input class="search-input" name="s" type="text" value="<?php echo esc_attr(get_search_query()); ?>" placeholder="<?php echo esc_attr_x( 'Search …', 'placeholder' ) ?>" title="<?php echo esc_attr_x( 'Search for:', 'label' ) ?>" /> <span class="button-wrapper"> <input class="button-search" type="submit" value="<?php echo esc_attr(__('Find', 'moto-juice')); ?>" /> </span> </div> </form>
Согласно официальной документации, если в теме присутствует файл searchform.php, то при вызове функции get_search_form() будет отображена форма поиска именно из этого файла. Это помогло мне вернуть потерянную кнопку.
Стандартный шаблон Twenty Fourteen уже переведён на множество языков, но в своей теме Moto Juice я добавил некоторые дополнительные строки, которые тоже нуждались в переводе. Локализация тем для движка WordPress базируется на технологии GNU gettext, которая достаточно проста в использовании. Эту систему локализации используют множество проектов, например, GTK+ и GNOME. Её основной принцип таков: из исходного кода необходимо получить обобщённый POT-файл, являющийся шаблоном и содержащий все строки, которые необходимо локализовать в теме. Затем из POT-файла нужно сгенерировать PO-файлы для определённого языка, с которыми может работать переводчик, занимающийся локализацией программного обеспечения. После того как перевод закончен, PO-файлы нужно скомпилировать в компактные бинарные MO-файлы, с которыми и будет работать движок WordPress.
Тема Twenty Fourteen уже использовала домен перевода twentyfourteen, но я решил для своей темы добавить отдельный домен moto-juice и зарегистрировать его в functions.php следующим образом:
load_theme_textdomain( 'twentyfourteen', get_stylesheet_directory() . '/languages/default' ); load_theme_textdomain( 'moto-juice', get_stylesheet_directory() . '/languages' );
Для того, чтобы шаблон Moto Juice перестал зависеть от системных файлов WordPress, я переместил стандартные файлы локализации Twenty Fourteen в каталог languages/default/, а мои собственные на уровень выше, в директорию languages/.
Далее, все необходимые строки, которые мне нужно было перевести, я обернул в специальную функцию __(), с которой работает gettext:
<div class="design-sign"> <?php printf( __( 'Designed by ', 'moto-juice' ) ); ?> <a href="<?php echo esc_url( __( '//exlmoto.ru/', 'moto-juice' ) ); ?>"><?php printf( __( 'EXL', 'moto-juice' ) ); ?></a> </div><!--.design-sign-->
Для нахождения таких строк в исходных файлах темы и генерации итогового POT-шаблона используется специальная утилита makepot.php, о том как её установить и как ей пользоваться можно прочитать в разделе официальной документации WordPress, который посвящен локализации тем. Суть сводится примерно к следующему:
svn co http://develop.svn.wordpress.org/trunk/tools/ ln -s . src php tools/i18n/makepot.php wp-theme src/wp-content/themes/MotoJuice/
В начале скачивается исходный код инструментов из репозитория WordPress, затем пробрасывается симлинк на директорию с исходным кодом WordPress и нужным шаблоном, а потом утилитой makepot.php генерируется POT-файл, который называется MotoJuice.pot в моём случае.
С файлом MotoJuice.pot уже может работать переводчик с помощью привычных ему прикладных инструментов для перевода. Разработчики WordPress советуют использовать кросс-платформенную программу Poedit, которая умеет создавать из POT-шаблонов как PO-файлы, так и скомпилированные MO-файлы.
Итак, открываем файл MotoJuice.pot с помощью программы Poedit, нажимаем кнопку «Create New Translation» и выбираем требуемый язык. После перевода необходимых строк сохраняем результат с помощью кнопки «Save», которая создаст как PO-файл, так и его скомпилированную MO-версию. Забрасываем эти два файла в каталог languages/ и всё, перевод шаблона на нужный язык, собственно, выполнен. Всё просто и элементарно, основная сложность заключается лишь в генерации изначального POT-шаблона.
Если будет необходимо просто поправить перевод, например, исправить опечатку, то генерировать снова POT-файл не требуется. Нужно лишь открыть PO-файл программой Poedit, сделать в нём нужные изменения, сохранить их, при этом автоматически обновится и скомпилированный MO-перевод. Повторная генерация POT-файла необходима тогда, когда в самом шаблоне были добавлены или удалены какие-либо строки для перевода. Поэтому очень важно начинать работать над переводом когда вся основная работа уже выполнена и строки в PHP-файлах гарантированно не будут изменяться. Так можно избежать повторяющейся работы, сэкономить время и сократить количество генераций POT-файлов.
Более подробно изучить вопросы локализации WordPress-тем можно на сайте WordPress Codex, в разделе I18n for WordPress Developers.
Процесс разработки собственного шаблона для WordPress на основе стандартной темы познакомил меня с некоторыми современными изысками Web-технологий. Я немного вник в суть разработки Web-сайтов на движке WordPress и смог опробовать в деле такие языки программирования, как PHP и JavaScript. Я понял, что быть сегодня Web-разработчиком довольно трудно, так как это самая быстроразвивающаяся отрасль IT и нужно быть постоянно в тренде всех текущих событий. Дополнительно я разобрался с тем, как локализовывать и подготавливать для релиза свои темы.
При создании шаблона Moto Juice я вдохновлялся дизайном таких сайтов, как Orange Smoothie Productions (OSP) и Ogre3D. Их чистый, лаконичный и простой дизайн мне очень сильно понравился.
Помимо обновления дизайна блога, я исправил разметку во всех его постах и добавил огромное количество разнообразного старого материала. В основном, добавленный материал посвящён портам приложений на платформу MotoMAGX и смартфон Motorola ZN5. Это было замечательное время, благодаря которому я познакомился с GNU/Linux на мобильных устройствах. Количество страниц на ресурсе изменилось с 15 до 22. Кроме того, на сайт была добавлена новая страница с моими актуальными на сегодняшний день проектами для самых различных платформ.
Поскольку тема Twenty Fourteen была выпущена под лицензией GNU GPLv2, я обязан предоставить все изменения, которые я внёс в этот шаблон. С огромной радостью я публикую тему Moto Juice в своём репозитории на GitHub, под этой же лицензией:
https://github.com/EXL/MotoJuice
Любой желающий может воспользоваться моими наработками и установить эту тему на свою копию движка WordPress. Для этого достаточно склонировать репозиторий, сделать ZIP-пакет и установить его через админку: «Appearance» => «Themes» => «Add New» => «Upload Theme» => «Install Now». Создать готовый к установке ZIP-пакет с шаблоном Moto Juice можно следующими командами:
git clone https://github.com/EXL/MotoJuice cd MotoJuice/ git archive master --prefix='moto-juice/' --format=zip > MotoJuice.zip
Будьте внимательны! Префикс темы, в моём случае moto-juice/, обязательно должен быть набран маленькими буквами, а вместо пробела должен использоваться знак минуса. В противном случае движок WordPress с темой будет работать некорректно, например, её будет невозможно удалить стандартными средствами.
Скачать собранный ZIP-пакет шаблона Moto Juice версии 1.0 можно по этой ссылке:
Остальные и последние версии шаблона можно найти здесь.
Если вы используете на своём ресурсе для отслеживания статистики посещений сервис Google Analitycs, то обязательно впишите свой корректный идентификатор UA ID в файл analyticstracking.php, который находится в корневой директории темы.
Тема Moto Juice имеет адаптивную вёрстку и ей удобно пользоваться как на смартфонах и планшетах, так и на настольных компьютерах.
Для проверки вашего WordPress-шаблона на типовые ошибки, я рекомендую использовать плагин, который называется Theme Check. Лично мне этот плагин помог выявить ошибку, связанную с неправильным названием домена темы.
В этой работе я использовал огромное количество материалов, основные из них я выделю в полезных ссылках ниже. Огромное спасибо ресурсам stackoverflow.com и google.com за то, что они есть.
Отдельную благодарность хочу выразить Zorge.R‘у за просто офигенный логотип в шапку ресурса и полезные советы, yakimka‘е за полезные советы и справедливую/объективную критику и sinbad‘у (Steve Streeting) за то, что мотивировал меня заняться собственной темой.
]]>Так как я уже достаточно давно являюсь счастливым пользователем KDE, я решил реализовать что-нибудь интересное для этого окружения, такое, чтобы радовался глаз. Создать анимированные обои с красивой анимацией было бы весьма кстати, поскольку стандартные совсем скучные и не демонстрируют мощь и функциональность технологии Qt Quick, а я как раз хотел опробовать язык программирования QML в разработке прикладных приложений.
Несколько лет назад, читая популярный в рунете IT-ресурс Хабрахабр я наткнулся на интересный проект: Часы на кривых Безье, где разработчик Jack Frigaard реализовал с помощью библиотеки для языка программирования JavaScript — Processing.js анимацию текущего времени. Используя цифры, отрисованные кривыми Безье и наборы простейших интерполяций, разработчику удалось получить очень интересную и занимательную трансформацию одних чисел в другие.
Мне настолько сильно понравилась эта анимация, что я вспомнил об этом проекте и решил использовать его в качестве базы для своих анимированных обоев в KDE Plasma 5. К моему огромному сожалению, ссылки из поста на Хабрахабре спустя два года стали мёртвыми, но я нашёл на хостинге Github Pages оригинальный сайт автора и его часы: Bézier Clock. На этой странице щедрый Jack Frigaard поделился с общественностью исходным кодом своего проекта, что несказанно облегчило реализацию моей идеи. По сути конечная цель свелась лишь к портированию Processing-кода на стек технологий Qt Quick и QML, а работающий прототип был создан всего за один вечер и две чашки крепкого кофе.
1. Обзор оригинального кода Bézier Clock
2. Особенности создания анимированных обоев в KDE Plasma 5
3. Портирование Bézier Clock на Qt Quick и QML
4. Создание окна настройки параметров Bezier Clock
5. Создание standalone-приложения Bezier Clock
6. Сборка пакетов для GNU/Linux и их установка
7. Заключение, полезные ссылки и ресурсы
Как уже было сказано выше, Jack Frigaard для своего проекта использовал библиотеку Processing.js, которая является портом языка визуализации данных Processing на язык программирования JavaScript. Библиотека интерпретирует код программы в файле с расширением *.pde и отображает требуемое изображение на экране. По заявлению разработчика на его официальном сайте, исходный код Bézier Clock получился очень простым и понятным, что на деле полностью соответствует действительности.
В программе содержится всего два класса: BezierDigit и BezierDigitAnimator. Первый класс описывает цифры и кривые Безье из которых они составлены, а второй отвечает за анимацию. В глобальном методе setup() настраивается контекст, создаются десять экземпляров класса BezierDigit, в соответствии с цифрами от нуля до девяти, затем создаются шесть объектов класса BezierDigitAnimator, по одному на каждую отображаемую цифру. Глобальный метод draw() вызывается каждый раз при обновлении кадра, именно он и формирует конечное изображение на экран. В этом методе в переменную d сохраняется текущая дата и время, из которой выделяются часы, минуты и секунды, потом они передаются объектам-аниматорам в их единственный метод update(). В нём происходит определение типа анимации (линейная, квадратичная, кубическая или синусоидальная) в соответствии с выбранными параметрами, а затем вспомогательным методом bezierVertexFromArrayListsRatios() рисуется текущая кривая Безье на экран. Внутри своего тела этот метод активно использует функцию lerp(), которая занимается линейной интерполяцией переданных в неё координат. Опционально в методах update() объектов класса BezierDigitAnimator рисуется ещё одна кривая Безье для тени и несколько прямых и примитивов для отображения контрольных линий.
Для переключения чисел используется глобальный вспомогательный метод getNextInt(), а метод getAnimStartRatio() предназначен для вычисления начала отсчёта анимации при повторном вызове функции draw().
float getAnimStartRatio(float totalDuration) { if (animDurationUser > totalDuration) { return 0; } else { return 1.0 - (animDurationUser / totalDuration) } } int getNextInt(int current, int max) { if (current >= max) { return 0; } else { return current + 1; } }
Методы mousePressed() и keyPressed() отвечают за изменение основных параметров приложения во время работы.
Вся красивая анимация этих часов заключается в своевременной интерполяции пяти контрольных точек кривых Безье между заранее заданными значениями.
Для того, чтобы приложение появилось в списке обоев в настройках рабочего стола KDE Plasma 5, каталог проекта нужно поместить в пользовательскую директорию ~/.local/share/plasma/wallpapers/ или системный каталог /usr/share/plasma/wallpapers/. Название директории, в которой находится обоина, должно быть задано в Java-стиле вида domain.organization.name, например, у меня это ru.exlmoto.bezierclock.
Стандартные анимированные обои KDE Plasma 5 доступны в системном каталоге /usr/share/plasma/wallpapers/. Поскольку QML является интерпретируемым языком, их код доступен в открытом и читаемом виде. Структура директории с обоиной довольно простая и выглядит следующим образом:
$ tree . ├── contents │ ├── config │ │ └── main.xml │ └── ui │ ├── config.qml │ └── main.qml ├── metadata.desktop └── plasma-wallpaper-color.desktop 3 directories, 5 files
В директории contents/config/ находится конфигурационный файл main.xml с дефолтными значениями параметров. В директории contents/ui/ располагаются скрипты на языке программирования QML, из которых config.qml отвечает за содержимое виджета, который будет отображён в настройках рабочего стола при выборе соответствующей обоины, а в main.qml находится точка входа в приложение и, соответственно, все инструкции, которые определяют изображение и анимацию. В файлах с расширением *.desktop содержится различная мета-информация пакета с обоиной.
Мой файл с мета-информацией metadata.desktop для Bezier Clock выглядит вот так:
[Desktop Entry] Encoding=UTF-8 Name=Bezier Clock Icon=preferences-system-time Type=Service ServiceTypes=Plasma/Wallpaper X-KDE-PluginInfo-Name=ru.exlmoto.bezierclock X-KDE-PluginInfo-Version=1.0 X-KDE-PluginInfo-Author=Serg Koles X-KDE-PluginInfo-Email=exlmotodev@gmail.com X-KDE-PluginInfo-Website=http://exlmoto.ru/bezier-clock X-KDE-PluginInfo-License=MIT X-Plasma-MainScript=ui/main.qml
Прочитать настройки из конфигурационного файла достаточно легко, для этого в KDE Plasma 5 имеются специальные системные Qt Quick-пакеты с необходимым набором API.
Например, в конфиге main.xml определён параметр Color типа Color. Тип параметра может быть любым: String, Int, Bool и другие популярные типы.
<group name="General"> <entry name="Color" type="Color"> <label>Color of the wallpaper</label> <default>#0000ff</default> </entry> </group>
Для того, чтобы получить значение параметра из QML, нужно импортировать системный пакет org.kde.plasma.core и использовать элемент wallpaper.configuration для получения необходимых данных:
import QtQuick 2.0 import org.kde.plasma.core 2.0 Rectangle { id: root color: wallpaper.configuration.Color Behavior on color { ColorAnimation { duration: units.longDuration } } }
Чтобы записать изменяемое значение в конфиг, нужно тоже импортировать пакет org.kde.plasma.core и прокинуть псевдоним на требуемое свойство со специальным префиксом вида cfg_OptionName:
import org.kde.plasma.core 2.0 Column { id: root property alias cfg_Color: colorDialog.color ... }
Тогда при изменении параметра, он будет автоматически сохранён в пользовательскую кофигурацию.
Следует помнить, что базовые элементы файлов config.qml и main.qml в качестве id должны иметь root, чтобы KDE Plasma 5 мог с ними работать, например, растягивать их контекст на весь экран.
Особенностью языка программирования QML является то, что он был создан декларативным. То есть вместо стандартного императивного подхода, который описывает как нужно решить задачу, в декларативном QML нужно описать что представляет из себя задача и её ожидаемый результат. Главный файл QML-приложения (в моём случае main.qml), определяет взаимодействия и всю иерархию элементов, дерево которых строится при загрузке QML-файла. Для каждого элемента определяется набор его типизированных свойств, в качестве значений которых могут быть использованы не только константы, но и выражения языка программирования JavaScript. Программы на QML могут использовать вспомогательные функции из внешних JavaScript-файлов или библиотек. Если в ООП базовым элементом является класс, по которому строится объект, то в QML базовым элементом будет сам элемент, являющийся строительным блоком QML-программы. Элементы бывают графическими и поведенческими, они объединяются вместе в QML-файлах для построения сложных компонентов. На этом заканчивается мой краткий экскурс в особенности языка программирования QML.
Как было сказано выше, в QML отсутствуют классы, но есть элементы, поэтому я начал портирование с преобразования классов BezierDigit и BezierDigitAnimator в компоненты BezierDigit.qml и BezierAnimator.qml соответственно. В качестве их базового элемента я выбрал компонент Item. QML-элементы внутри можно расширять методами на JavaScript, чем я воспользовался и портировал в них код с Processing. Для файла BezierDigit.qml получилось следующее:
import QtQuick 2.0 Item { property real vertexX property real vertexY property variant controls: [] function initDigit(control0, control1, control2, control3) { for (var i = 0; i < 4; ++i) { controls[i] = new Array(6); } controls[0] = control0; controls[1] = control1; controls[2] = control2; controls[3] = control3; } function getControl(index) { var scaledControl = new Array(6); for (var i = 0; i < 6; i++) { scaledControl[i] = controls[index][i] * setup.visualScaling; } return scaledControl; } function getVertexX() { return vertexX * setup.visualScaling; } function getVertexY() { return vertexY * setup.visualScaling; } function initialize() { controls = new Array(4); } Component.onCompleted: { initialize(); } }
Инициализацию этих цифр кривыми Безье я поручил элементу BezierDigits.qml, вот его код:
import QtQuick 2.0 Item { property BezierDigit digit_0: digit0 property BezierDigit digit_1: digit1 ... BezierDigit { // Digit 0 id: digit0 vertexX: 254.0; vertexY: 47.0; Component.onCompleted: { initDigit([159.0, 84.0, 123.0, 158.0, 131.0, 258.0], [139.0, 358.0, 167.0, 445.0, 256.0, 446.0], [345.0, 447.0, 369.0, 349.0, 369.0, 275.0], [369.0, 201.0, 365.0, 81.0, 231.0, 75.0]); } } BezierDigit { // Digit 1 id: digit1 vertexX: 138.0; vertexY: 180.0; Component.onCompleted: { initDigit([226.0, 99.0, 230.0, 58.0, 243.0, 43.0], [256.0, 28.0, 252.0, 100.0, 253.0, 167.0], [254.0, 234.0, 254.0, 194.0, 255.0, 303.0], [256.0, 412.0, 254.0, 361.0, 255.0, 424.0]); } } ... }
К сожалению, в официальной документации сказано, что порядок вызова кода у компонентов в свойствах Component.onCompleted не определён. Поэтому я не решился сделать массив variant, содержащий элементы BezierDigit и стал использовать несколько громоздкий прямой доступ к каждому элементу. Если у меня появится свободное время, я уточню этот момент у знакомых Qt Quick/QML-программистов.
Выше я отметил, что запуск анимированной обоины начинается с файла main.qml, он получился у меня достаточно компактным:
import QtQuick 2.0 Rectangle { id: root width: 800 height: 480 color: setup.backgroundColor BezierSettings { id: settings } BezierSetup { id: setup } BezierClock { id: bezierClock anchors.centerIn: parent } }
Элемент BezierSettings отвечает за чтение настроек из конфига в свои свойства, это сделано специально для разделения кода анимированной обоины для KDE Plasma 5 и standalone-приложения на Qt Quick/QML, о чём я упомяну позже.
import QtQuick 2.0 import org.kde.plasma.core 2.0 // For wallpaper.configuration Item { property color backgroundColor: wallpaper.configuration.BackgroundColor property real visualScaling: wallpaper.configuration.ScalingValue / 10 property real animDurationUser: wallpaper.configuration.DurationAnim / 100 property bool continualAnimation: wallpaper.configuration.ContinualAnimation ... }
Компонент BezierSetup забирает из свойств BezierSettings необходимые опции и создаёт элемент BezierInit:
Item { property int yOff: 0 property color backgroundColor: settings.backgroundColor property real visualScaling: settings.visualScaling property real animDurationUser: settings.animDurationUser property bool continualAnimation: settings.continualAnimation ... BezierInit { id: _init } }
BezierInit в своём теле инициализирует элементы-аниматоры и компонент BezierDigits, который обобщает все цифры:
Item { property BezierDigits digits: _digits property BezierAnimator hoursTensAnimator: _hoursTensAnimator property BezierAnimator hoursUnitsAnimator: _hoursUnitsAnimator property BezierAnimator minutesTensAnimator: _minutesTensAnimator property BezierAnimator minutesUnitsAnimator: _minutesUnitsAnimator property BezierAnimator secondsTensAnimator: _secondsTensAnimator property BezierAnimator secondsUnitsAnimator: _secondsUnitsAnimator BezierDigits { id: _digits } BezierAnimator { // X0:00:00 id: _hoursTensAnimator origX: 0.0 * visualScaling origY: yOff * visualScaling animationStartRatio: 35995.0 / (35995.0 + 5.0) } ... }
Далее в main.qml создаётся главный элемент анимированных обоев — BezierClock, который содержит в себе элемент канваса для отрисовки происходящего на экране, компонент, отвечающий за вывод оверлея счётчика FPS и таймер, в котором происходит обновление канваса в соответствии со значением FPS:
import QtQuick 2.0 Rectangle { width: 2300 * setup.visualScaling height: 600 * setup.visualScaling BezierCanvas { id: canvas anchors.fill: parent } UiFpsOverlay { id: fps } // Main Timer Timer { id: timer interval: setup.frameRate repeat: true triggeredOnStart: true onTriggered: { fps.framesCount++; fps.framesPerSecond++; canvas.requestPaint(); } } Component.onCompleted: { timer.start(); fps.timer.start(); } }
Для различных оверлеев я создал базовый элемент UiBaseOverlay, с таким содержанием:
import QtQuick 2.0 import 'JsCoreFunctions.js' as CoreFunctions // For const Rectangle { id: _base property int gap: 10 property alias textTo: textLabel.text property alias fontTo: textLabel.font.family width: textLabel.width + gap height: textLabel.height + gap color: 'black' opacity: CoreFunctions.DEFAULT_OVERLAY_OPACITY Text { id: textLabel x: gap / 2 y: gap / 2 color: 'white' opacity: parent.opacity font.family : 'monospace' } }
От него я и унаследовал UiFpsOverlay, который содержит ещё один таймер, тикающий раз в секунду:
import QtQuick 2.0 UiBaseOverlay { visible: setup.showFps // FPS Area property int framesCount: 0 property int framesPerSecond: 0 property int seconds: 0 property int fps: 0 property int fpsAverage: 0 // Fps Timer property Timer timer: timerOneSec textTo: framesCount + ' Frames in ' + seconds + ' seconds\n' + 'Current FPS: ' + fps + '\n' + 'Average FPS: ' + (fpsAverage / ((seconds > 1) ? seconds - 1 : 1)).toFixed(2); Timer { id: timerOneSec interval: 1000 // 1 Second repeat: true triggeredOnStart: true onTriggered: { if (seconds != 0) { fps = framesPerSecond; fpsAverage += framesPerSecond; framesPerSecond = 0; } seconds++; } } }
Оба таймера, главный и предназначенный для FPS, запускаются в свойстве Component.onCompleted элемента BezierClock. Это свойство передаёт своё управление JavaScript-коду тогда, когда компонент загрузился и выстроено дерево его элементов. Константы 2300 и 600 являются оригинальным размером контекста часов без какого-либо масштабирования.
В элементе BezierCanvas определён метод render(), являющийся аналогом метода draw() в Processing. Этот метод дёргает функции update() элементов-аниматоров и рисует итоговый кадр на канвас.
import QtQuick 2.0 import QtQml 2.0 // for new Date() import 'JsCoreFunctions.js' as CoreFunctions import 'JsCanvasFunctions.js' as CanvasFunctions Canvas { id: canvas property date currentDate property int currentSec onPaint: { var context2d = getContext('2d'); render(context2d); } function render(context) { context.save(); // Fill Background CanvasFunctions.fillCanvasByColor(context, setup.backgroundColor); // Translate Context CanvasFunctions.translateContex(context); // Render Seconds currentDate = new Date(); ... } }
Канвас в QML имеет множество методов, аналогичных канвасу в HTML5, а потому портирование различных приложений с QML на HTML5 и обратно не представляет особой сложности.
Некоторые компоненты я расширил JavaScript-файлами JsCanvasFunctions.js и JsCoreFunctions.js. Первый является компонентно-зависимым, а потому в его функциях можно получать доступ к свойствам QML-элемента. Второй, напротив, является библиотекой, и его можно использовать в различных QML-элементах. Для определения библиотеки имеется специальная директива .pragma library, которую необходимо определить в самом начале файла:
.pragma library var DEFAULT_OVERLAY_OPACITY = 0.7; function lerp(value1, value2, amount) { // amount = amount < 0 ? 0 : amount; // amount = amount > 1 ? 1 : amount; return ((value2 - value1) * amount) + value1; } function bezierVertexFromArrayListsRatios(context, from, to, ratio, offsetX, offsetY) { context.bezierCurveTo(lerp(from[0], to[0], ratio) + offsetX, lerp(from[1], to[1], ratio) + offsetY, lerp(from[2], to[2], ratio) + offsetX, lerp(from[3], to[3], ratio) + offsetY, lerp(from[4], to[4], ratio) + offsetX, lerp(from[5], to[5], ratio) + offsetY); } function getNextInt(current, max) { if (current >= max) { return 0; } else { return current + 1; } } function getAnimStartRatio(totalDuration, animDurationUser) { if (animDurationUser > totalDuration) { return 0; } else { return 1.0 - (animDurationUser / totalDuration) } } function sq(aNumber) { return aNumber * aNumber; } function roundOne(aNumber) { return (aNumber < 0.5) ? 0.0 : 1.0; } function drawLine(context, startX, startY, endX, endY) { context.beginPath(); context.moveTo(startX, startY); context.lineTo(endX, endY); context.stroke(); } function drawCircle(context, centerX, centerY, radius, fillColor, stroke) { context.beginPath(); context.arc(centerX, centerY, radius, 0, 2 * Math.PI, false); context.fillStyle = fillColor; context.fill(); if (stroke) { context.stroke(); } } function drawSquare(context, x, y, width, fillColor, strokeColor, baseStokeColor) { context.beginPath(); context.rect(x - width / 2, y - width / 2, width, width); context.fillStyle = fillColor; context.fill(); context.strokeStyle = strokeColor; context.stroke(); context.strokeStyle = baseStokeColor; } function getLineCap(aNumber) { switch (aNumber) { default: case 0: return 'butt'; case 1: return 'round'; case 2: return 'square'; } } function getAnimationType(aNumber) { switch (aNumber) { default: case 0: case 1: return 'linear'; case 2: return 'quadratic'; case 3: return 'cubic'; case 4: return 'sinuisoidial'; case 5: return 'no animation'; } }
В JsCoreFunctions.js я реализовал функции, доступные в Processing, но отсутствующие в JavaScript, такие, как sq() и lerp().
Мои эксперименты показали, что отрисовка канваса размером на весь экран требует значительных ресурсов CPU, поэтому я придумал небольшую хитрость: я рисую часы не в полный экран, а в небольшой прямоугольник, который находится в центре другого статичного полноэкранного прямоугольника. Поскольку цвета у прямоугольников одинаковые, этого не заметно, но зато FPS значительно повысился и перестал проседать.
На этом обзорная часть моего кода завершена. В качестве небольшого отличия от оригинала я реализовал между цифрами отрисовку мигающих (в зависимости от желания пользователя) точек, как в самых обычных электронных часах.
Как было отмечено выше, в настройках рабочего стола при выборе необходимой анимированной обоины отображается форма из файла config.qml, внутри которого описаны все необходимые элементы управления. Я решил сделать Bezier Clock максимально настраиваемым и определил более двадцати параметров, которые можно изменить в соответствии со своим вкусом и предпочтениями. Само собой, такое количество опций сильно раздувает окно настройки, но писать интерфейсы на языке программирования QML — одно удовольствие! Кажется, что это идеальный язык для такого рода задач.
Нужно определить колонки со строками и просто заполнить их компонентами в соответствии с необходимой иерархией. Элементы можно кастомизировать, совмещать и наследовать, в сочетании с мощью QML это даёт огромный простор для полёта фантазии. Например, для создания компонента выбора цвета UiColorBox, который отображает внутри себя кнопку с цветным прямоугольником и строку справа, требуется следующий код:
import QtQuick 2.0 import QtQuick.Controls 1.0 import QtQuick.Dialogs 1.1 import org.kde.plasma.core 2.0 // For units Item { property string titleDialog property string labelText property alias colorTo: colorDialog.color width: label.width + colorButton.width + units.smallSpacing height: colorButton.height ColorDialog { id: colorDialog modality: Qt.WindowModal showAlphaChannel: true title: titleDialog } Row { spacing: units.smallSpacing Button { id: colorButton width: units.gridUnit * 2 onClicked: colorDialog.open() Rectangle { id: colorRect anchors.centerIn: parent width: parent.width - units.smallSpacing * 4 height: parent.height - units.smallSpacing * 4 color: colorDialog.color } } Label { id: label anchors.verticalCenter: colorButton.verticalCenter text: labelText } } }
Похожим образом реализованы элементы UiComboBox и UiSpinBox.
Настройки сохраняются в пользовательской конфигурации посредством проброшенных псевдонимов на свойства с префиксом cfg_OptionName, KDE Plasma 5 при изменении настроек на форме тут же перезаписывает их в хранилище и делает доступной кнопку Apply. Часть файла config.qml, демонстрирующая чтение настроек и то, насколько просто описывается отображаемый интерфейс:
import QtQuick 2.0 import QtQuick.Controls 1.0 import org.kde.plasma.core 2.0 // For units import 'JsConfigUiHelper.js' as ConfigUiHelper Row { id: root // All settings property alias cfg_BackgroundColor: colorBackgroundBox.colorTo property alias cfg_DigitColor: colorDigitBox.colorTo property alias cfg_ShadowColor: colorShadowBox.colorTo property alias cfg_ControlLinesColor: colorLinesBox.colorTo property alias cfg_SquaresColor: colorRectsBox.colorTo ... spacing: units.smallSpacing Column { spacing: units.smallSpacing GroupBox { title: qsTr('Main Settings') id: mainGroupBox // This is element with biggest width Column { spacing: units.smallSpacing UiComboBox { id: animationTypeComboBox labelText: qsTr('Animation') modelTo: [qsTr('Linear'), qsTr('Quadratic'), qsTr('Cubic'), qsTr('Sinuisoidial'), qsTr('No animation')] onComboBoxIndexChanged: { if (comboBoxIndex == 4) { durationsSpinBox.enabled = false } else { durationsSpinBox.enabled = true } } } UiSpinBox { id: scalingSpinBox minValue: 1 maxValue: 20 stepValue: 1 labelText: qsTr('Scaling Value') } UiSpinBox { id: durationsSpinBox minValue: 0 maxValue: 10000 stepValue: 1 labelText: qsTr('Animation Duration') } CheckBox { id: continualAnimationCheckBox text: qsTr('Continual Animation') onCheckedChanged: { if (checked == true) { continualShadowsCheckBox.checked = false; } } } UiColorBox { id: colorBackgroundBox titleDialog: qsTr('Select Background Color') labelText: qsTr('Background Color') } } } } }
Поскольку вариантов конфигурирования Bezier Clock получилось огромное количество, пользователь может что-то сломать и забыть то, как вернуть всё в первоначальное состояние. Для этой цели я сделал кнопку Reset to Default, а функцию сброса настроек на те, что были по умолчанию, вынес в отдельный файл JsConfigUiHelper.js:
function resetToDefault() { // Reset all settings to Default values colorBackgroundBox.colorTo = '#FFFFFF'; // White colorDigitBox.colorTo = '#000000'; // Black colorShadowBox.colorTo = '#888888'; // Gray colorLinesBox.colorTo = '#FF0000'; // Red colorRectsBox.colorTo = '#0000FF'; // Blue continualAnimationCheckBox.checked = false; continualShadowsCheckBox.checked = false; controlLinesCheckBox.checked = false; showFpsCheckBox.checked = false; scalingSpinBox.spinBoxValue = 5; durationsSpinBox.spinBoxValue = 100; fpsLimitSpinBox.spinBoxValue = 60; digitsWidthSpinBox.spinBoxValue = 10; shadowsWidthSpinBox.spinBoxValue = 10; linesWidthSpinBox.spinBoxValue = 1; circlesRadiusSpinBox.spinBoxValue = 3; digitCapComboBox.comboBoxIndex = 1; animationTypeComboBox.comboBoxIndex = 0; showDotsCheckBox.checked = true; blinkCheckBox.checked = false; radiusDotsSpinBox.spinBoxValue = 5; }
В качестве небольшого украшения я добавил на форму собственноручно созданную иконку и информацию о создателях.
Программы, созданные с помощью технологии Qt Quick и языка программирования QML, легко переносимы на другие системы. Созданную анимированную обоину можно сделать обычным прикладным приложением и развернуть окно на весь экран там, где нет KDE Plasma 5, но есть Qt 5, например, на MS Windows или macOS. Тот код, который используется только для standalone-приложения, я вынес в отдельный каталог qml/NonKDE/ и использовал разделение. К примеру, отдельной программой используются файлы с постфиксом Qt: BezierSettingsQt.qml и mainQt.qml, а анимированная обоина для KDE Plasma 5 использует BezierSettings.qml и main.qml. Получается, что в случае standalone-приложения настройки в общий элемент BezierSetup загружаются компонентом BezierSettingsQt, а в случае анимированной обоины этой работой занимается BezierSettings. Таким образом можно легко разделять платформозависимый код.
Окно с настройкой параметров в standalone-приложении я решил не делать и вместо этого перенес изменение опций на клавиши клавиатуры и кнопки мышки. Для того, чтобы пользователь имел представление о том, каким образом изменился тот или иной параметр, я добавил простейшую систему полупрозрачных исчезающих оверлеев. Их смысл таков: пользователь нажимает на кнопку и видит сообщение о том, какой параметр изменился и как он изменился. Кроме того, с помощью такого же метода я организовал справку по клавишам, которые обрабатывает приложение. Для создания оверлея я просто унаследовался от общего базового элемента UiBaseOverlay и создал компонент UiOptionsOverlay, в котором создал поведенческий элемент NumberAnimation регулирующий свойство opacity и отвечающий за полное исчезновение оверлея с экрана в течении необходимого времени:
import QtQuick 2.0 import '../' UiBaseOverlay { property bool playAnimation: false property alias animation: _animation fontTo: 'courier new' visible: false NumberAnimation on opacity { id: _animation running: playAnimation to: 0.0 duration: 3000 // 3 seconds } onOpacityChanged: { if (!opacity) { visible = false; } } }
Файл mainQt.qml, отвечающий за главное окно программы, выглядит следующим образом:
import QtQuick 2.0 import QtQuick.Window 2.1 import '../' import 'JsMainUiHelper.js' as MainUiHelper import '../JsCoreFunctions.js' as CoreFunctions Window { id: rootWindow visible: true width: 800 height: 480 title: qsTr('Bezier Clock') BezierSettingsQt { id: settings } MouseArea { anchors.fill: parent acceptedButtons: Qt.LeftButton | Qt.RightButton onClicked: { if (mouse.button == Qt.RightButton) { setup.continualAnimation = !setup.continualAnimation; setup.showContinualShadows = false; MainUiHelper.showOptionsOverlay(qsTr('Main Settings'), qsTr('Continual Animation'), setup.continualAnimation); } else { setup.drawControlLines = !setup.drawControlLines; MainUiHelper.showOptionsOverlay(qsTr('Control Lines'), qsTr('Show Control Lines'), setup.drawControlLines); } } } Rectangle { width: parent.width height: parent.height color: setup.backgroundColor focus: true BezierSetup { id: setup } BezierClock { id: bezierClock anchors.centerIn: parent } UiOptionsOverlay { id: overlayHelp anchors.centerIn: parent animation.duration: 6000 // 6 seconds } UiOptionsOverlay { id: overlayOptions anchors.top: bezierClock.top anchors.right: bezierClock.right } Keys.onPressed: { MainUiHelper.keyPressed(event); } } Component.onCompleted: { MainUiHelper.checkFullscreen(); MainUiHelper.showHelpOverlay(); } Component.onDestruction: { MainUiHelper.saveSettings(); } }
В элементе MouseArea происходит обработка нажатий на кнопки мышки, а в свойстве Keys.onPressed — обработка клавиш клавиатуры. Различные функции я выделил во вспомогательный JavaScript-файл JsMainUiHelper.js, например, в нём содержатся методы обработки событий, отображения оверлеев overlayOptions и overlayHelp, а также функции генерации случайных цветов. Файл получился весьма громоздким, вот его небольшая часть кода:
function showOptionsOverlay(option, text, value) { overlayOptions.textTo = option + '\n' + text + ': ' + value; showOverlay(); } function showMessage(value) { overlayOptions.textTo = value; showOverlay(); } function showOverlay() { overlayOptions.visible = true; overlayOptions.opacity = 0.9; overlayOptions.animation.restart(); } function getRandomColor() { return Qt.rgba(Math.random(), Math.random(), Math.random(), 1.0); } function setRandomColors() { setup.backgroundColor = getRandomColor(); setup.digitColor = getRandomColor(); setup.digitColorShadow = getRandomColor(); setup.linesColor = getRandomColor(); setup.rectColor = getRandomColor(); } function checkFullscreen() { if (settings.fullscreen) { rootWindow.visibility = 'FullScreen'; } else { rootWindow.visibility = 'Windowed'; } } function toggleFullscreen() { settings.fullscreen = !settings.fullscreen; checkFullscreen(); }
Настройки приложения определены в BezierSettingsQt, и по сравнению с BezierSettings расширены опцией, отвечающей за работу окна в полноэкранном режиме. В Qt Quick существует специальный пакет Qt.labs.settings для работы с настройками. Элемент settings обеспечивает кроссплатформенное сохранение и чтение необходимых параметров из внутреннего хранилища системы. Настройки читаются при каждом запуске приложения и сохраняются после выхода из приложения свойством Component.onDestruction корневого элемента mainQt.qml.
В standalone-приложении необходимый исходный код на языках QML и JavaScript вкомпиливается прямо в исполнительный бинарник. Работу по упаковке исходного кода выполняет специальный компилятор ресурсов rcc, который получает список необходимых компонентов из файла qml.qrc. Главный компилируемый файл main.cpp на языке программирования C++ выглядит следующим образом:
#include <QGuiApplication> #include <QQmlApplicationEngine> #include <QIcon> int main(int argc, char *argv[]) { QGuiApplication app(argc, argv); app.setWindowIcon(QIcon("://images/BC_icon.png")); app.setOrganizationName("EXL\'s Group"); app.setOrganizationDomain("exlmoto.ru"); app.setApplicationName("Bezier Clock"); QQmlApplicationEngine engine; engine.load(QUrl(QStringLiteral("qrc:/qml/NonKDE/mainQt.qml"))); return app.exec(); }
Собрать и запустить standalone-приложение достаточно просто. Необходимо перейти в каталог с исходным кодом и выполнить следующие команды в терминале:
$ qmake BezierClock.pro $ make -j9 $ ./BezierClock
Таким образом развернуть Bezier Clock можно на любых системах, для которых доступен Qt 5.
Для удобной установки Bezier Clock в дистрибутивы GNU/Linux, я решил создать два варианта пакетов: первый подходит для любого дистрибутива и уставливает обоину в пользовательскую директорию ~/.local/share/plasma/wallpapers/, а второй является установочным пакетом для дистрибутива Arch Linux и уставливает файлы в системный каталог /usr/share/plasma/wallpapers/.
Первый вариант пакета представляет собой обычный архив формата TAR.XZ и установочный скрипт package.sh, выполнение которого развернёт пакет в пользовательскую директорию. Он выглядит следующим образом:
#!/bin/bash # Name VERSION="v1.0" PACKAGE_NAME=bezier-clock-$VERSION.tar.xz # Dirs CURRENT_DIR="`pwd`" PACKAGE_DIR="$CURRENT_DIR/ru.exlmoto.bezierclock" UI_DIR="$PACKAGE_DIR/contents/ui" ICON_DIR="$PACKAGE_DIR/contents/icon" CONFIG_DIR="$PACKAGE_DIR/contents/config" DEPLOY_DIR="$HOME/.local/share/plasma/wallpapers" APP_DIR="$DEPLOY_DIR/ru.exlmoto.bezierclock" func_package() { # Create dirs echo -n "Prepare directory $PACKAGE_DIR for package app..." mkdir -p $PACKAGE_DIR mkdir -p $UI_DIR mkdir -p $ICON_DIR mkdir -p $CONFIG_DIR # Copy package files cp metadata.desktop $PACKAGE_DIR cp ../qml/{*.qml,*.js} $UI_DIR cp ../images/BC_icon.png $ICON_DIR cp ../xml/main.xml $CONFIG_DIR echo "done." # Archive files echo -n "Package files to $PACKAGE_NAME..." tar -cJf $PACKAGE_NAME ru.exlmoto.bezierclock echo "done." # Clean echo -n "Cleaning $PACKAGE_DIR..." rm -Rf $PACKAGE_DIR echo "done." } func_install() { if [ ! -f ${PACKAGE_NAME} ]; then echo "$PACKAGE_NAME not found! Create package first with ./package.sh -p" ; exit 1 else if [ -d $APP_DIR ]; then echo "$PACKAGE_NAME already installed! Remove package first with ./package.sh -u" ; exit 1 else # Create dirs mkdir -p $DEPLOY_DIR # Unpack package echo -n "Install package to $DEPLOY_DIR..." tar -xJf $PACKAGE_NAME -C $DEPLOY_DIR echo "done." fi fi } func_uninstall() { # Delete Package if [ -d $APP_DIR ]; then echo -n "Deleting $APP_DIR..." rm -Rf $APP_DIR echo "done." else echo "Package $PACKAGE_NAME already uninstalled." fi } func_usage() { echo -e "\nUsage ./package.sh for install package or ./package.sh <argument> for options:\n" echo -e "\t -i or --install\tfor create package and install it to $DEPLOY_DIR;" echo -e "\t -p or --package\tfor create TAR.XZ-package;" echo -e "\t -u or --uninstall\tfor uninstall package (removing $APP_DIR directory);" echo -e "\t -c or --clean\t\tfor remove package from current directory;" echo -e "\t -h or --help\t\tfor this help.\n" } func_clean() { if [ -f ${PACKAGE_NAME} ]; then echo -n "Deleting $PACKAGE_NAME..." rm $PACKAGE_NAME echo "done." else echo "$PACKAGE_NAME already deleted." fi } if test $# -ne 0; then case "$1" in "") func_usage ;; "-i"|"--install") func_install ;; "-p"|"--package") func_clean ; func_package ;; "-u"|"--uninstall") func_uninstall ;; "-c"|"--clean") func_clean ;; "-h"|"--help") func_usage ;; *) func_usage ;; esac shift else func_install ; exit 1 fi
Скрипт, будучи запущенный с опцией -u, удаляет файлы Bezier Clock из пользовательской директории. В теории он должен работать на любых дистрибутивах GNU/Linux с KDE Plasma 5. Помимо опций установки и удаления этот скрипт как раз и собирает пакет bezier-clock-v1.0.tar.xz, если его запустить в каталоге исходного кода utils/ с опцией сборки пакета -p.
Поскольку сейчас я использую замечательный дистрибутив GNU/Linux под названием Arch Linux, я решил попрактиковаться в создании пакетов и собрать Bezier Clock именно для этого дистрибутива. Для этого в Arch Linux нужно установить некоторые зависимости и систему сборки abs — Arch Build System, после чего нужно выполнить синхронизацию дерева abs с сервером и можно приступать к созданию пакета.
Update 09-FEB-2018: Система сборки Arch Build System устарела, поэтому устанавливать её для сборки пакетов больше не требуется.
$ sudo pacman -S abs $ sudo pacman -S extra-cmake-modules $ sudo abs
Чтобы в строке Packager при выполнении команды запроса информации о пакете:
$ sudo pacman -Qi bezier-clock
Отображалось ваше имя и почта, необходимо задать эти данные в файле /etc/makepkg.conf в переменной PACKAGER раздела PACKAGE OUTPUT.
Для создания пакета потребуется написать лишь два файла: рецепт любой системы сборки и сборочный скрипт PKGBUILD. В качестве сборочной системы я выбрал CMake, так как именно её использует KDE Plasma 5 и в ней имеются все необходимые скрипты и переменные для этого окружения. Пример рецепта CMake для установки своих элементов (обоев, виджетов, апплетов) в KDE Plasma 5 опубликован в официальной документации. Я его немного отредактировал и получилось следующее:
project(bezier-clock) # Set minimum CMake version (required for CMake 3.0 or later) cmake_minimum_required(VERSION 2.8.12) # Use Extra CMake Modules (ECM) for common functionality. # See http://api.kde.org/ecm/manual/ecm.7.html # and http://api.kde.org/ecm/manual/ecm-kde-modules.7.html find_package(ECM REQUIRED NO_MODULE) # Needed by find_package(KF5Plasma) below. set(CMAKE_MODULE_PATH ${ECM_MODULE_PATH} ${ECM_KDE_MODULE_DIR} ${CMAKE_MODULE_PATH}) # Locate plasma_install_package macro. find_package(KF5Plasma REQUIRED) # Add installatation target ("make install"). plasma_install_package(bezier-clock ru.exlmoto.bezierclock wallpapers wallpaper)
Сборочный скрипт PKGBUILD имеет специальные функции prepare(), build() и package(). Первая готовит окружение для сборки, вторая выполняет саму сборку, а третья делает пакет. Мой PKGBUILD выглядит следующим образом:
pkgname=bezier-clock ver=1.0 pkgver=v$ver pkgrel=1 pkgdesc='Bezier Clock live wallpaper for the Plasma Workspace' arch=('any') url='http://exlmoto.ru/bezier-clock' license=('MIT') depends=('plasma-workspace') source=("https://github.com/EXL/BezierClock/archive/$pkgver.tar.gz") sha256sums=('ca60121292eb78a2143f09651a2c43dbe3fdcd0cda2127603d09f0f3eaffe765') makedepends=('extra-cmake-modules') # Directories base_dir=build-bezier-clock-package build_dir=$base_dir/build app_dir=$base_dir/bezier-clock contents_dir=$app_dir/contents prepare() { mkdir -p $build_dir mkdir -p $contents_dir/{ui,icon,config} cp BezierClock-$ver/utils/*.desktop $app_dir cp BezierClock-$ver/qml/{*.qml,*.js} $contents_dir/ui cp BezierClock-$ver/images/BC_icon.png $contents_dir/icon cp BezierClock-$ver/xml/main.xml $contents_dir/config cp BezierClock-$ver/utils/CMakeLists.txt $base_dir } build() { cd $build_dir cmake .. \ -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_INSTALL_PREFIX=/usr \ -DBUILD_TESTING=OFF make } package() { cd $build_dir make DESTDIR="${pkgdir}" install }
Для сборки пакета достаточно выполнить команду:
$ makepkg -cf
В той директории, где находится PKGBUILD. Умный abs сам скачает исходный код, проверит его хеш-сумму, разархивирует, соберёт и создаст пакет bezier-clock-v1.0-1-any.pkg.tar.xz, который будет удобно устанавливать в систему при помощи пакетного менеджера pacman или его фронтеда yaourt:
$ sudo pacman -U bezier-clock-v1.0-1-any.pkg.tar.xz
За удаление пакета из системы тоже будет отвечать пакетный менеджер.
$ sudo pacman -R bezier-clock
Благодаря системе сборки CMake можно легко собрать пакеты анимированных обоев Bezier Clock и для других дистрибутивов GNU/Linux.
Кроме того, в KDE Plasma 5 Workspace имеется специальный встроенный пакетный менеджер, который называется Plasma Package Manager. Можно установить пакет bezier-clock-v1.0.tar.xz, инструкцию по сборке которого я написал чуть выше, с его помощью следующим образом:
$ plasmapkg2 -t wallpaperplugin -i bezier-clock-v1.0.tar.xz
После чего пакет установится аналогино первому способу. Удалить его из системы тоже можно через Plasma Package Manager:
$ plasmapkg2 -t wallpaperplugin -r ru.exlmoto.bezierclock
Эта утилита позволяет удобно управлять различными расширениями KDE Plasma 5.
Создание анимированных обоев Bezier Clock для KDE Plasma 5 позволило мне разобраться в написании приложений с использованием технологии Qt Quick на языке программирования QML, узнать о таком языке визуализации данных, как Processing и успешно попрактиковаться в создании установочных пакетов для Arch Linux. Этот процесс дал мне огромное количество ценного опыта, я смог понять механизм работы анимированных обоев в KDE Plasma 5 и разобраться во внутренней кухне сборки пакетов для своего дистрибутива GNU/Linux.
Размер установочных TAR.XZ-пакетов получился совсем крошечным, всего 15 КБ. Это связано с тем, что QML является интерпретируемым языком, а интерпретатор уже установлен в систему.
Скачать TAR.XZ-пакеты Bezier Clock, готовые для установки в дистрибутивы GNU/Linux, можно по этим ссылкам:
[Скачать Plasma TAR.XZ-пакет Bezier Clock, 13 КБ | Download Plasma Bezier Clock 21 TAR.XZ-package, 13 КB]
[Скачать All TAR.XZ-пакет Bezier Clock, 15 КБ | Download All Bezier Clock 21 TAR.XZ-package, 15 КB]
[Скачать Arch Linux TAR.XZ-пакет Bezier Clock, 15 КБ | Download Arch Linux Bezier Clock 21 TAR.XZ-package, 15 КB]
К сожалению, в последних версиях KDE Plasma 5.7.x внесли баг, из-за которого не читаются и не сохраняются настройки анимированных обоев. Поэтому после установки и активации Bezier Clock можно увидеть чёрный экран. Для исправления этой ошибки нужно просто нажать кнопку Reset to Default, а потом Apply. Этот баг зарепорчен и можно наблюдать за исправлением ситуации здесь и здесь.
Все исходные коды и проект в целом выложен в репозиторий на ресурсе Github. Мои изменения и исходные файлы доступны под лицензией MIT. Ссылка на репозиторий:
https://github.com/EXL/BezierClock
В этой работе я использовал огромное количество материалов, основные из них я выделю в полезных ссылках ниже. Огромное спасибо ресурсам stackoverflow.com и google.com за то, что они есть.
Update 06-MAR-2017: Прочитать о твике Bezier Clock для KDE Plasma 5, который добавляет возможность установки собственного фонового изображения, можно в разделе этой статьи.
]]>История Snood 21 берёт своё начало с оригинальной игры Snood, которую разработал David M. Dobson в 1996 году для персональных компьютеров Mac от Apple. Именно с этой игры и началась история незамысловатых и забавных персонажей, которые называются Снудами. Вселенная Снудов включает в себя огромное количество самых разных настольных, карточных и аркадных игр, которые теперь доступны на официальном сайте под различные платформы. К несчастью, я не смог найти там игру, аналогичную Snood 21. Видимо она выходила только на мобильные телефоны от Motorola.
Разработчиком игры Snood 21 является компания THQ, которая в сотрудничестве с Motorola выпустила эту игру встроенной в прошивку различных бюджетных мобильных телефонов. THQ купила права использования имён персонажей и их изображений у идейного вдохновителя и даже создала Java-версию игры для монохромных телефонов с поддержкой Java. Помимо этого, существует и нативная версия для карманного компьютера Cybiko.
Правила игры довольно просты: необходимо помещать карты из колоды в столбцы, набирать в них 21 очко и стараться попасть в таблицу рекордов. Масти в игре заменены Снудами, кроме этого имеется несколько специальных карт, которые сразу очищают колонку. На подсчёт очков влияет столбец, в котором была собрана комбинация. Чем он правее, тем больше очков можно заработать. Игра заканчивается если истечёт время или заблокируются все столбцы. Более подробно правила Snood 21 описаны в инструкции, которая шла вместе с мобильным телефоном Motorola.
В своём ремейке для Android OS я решил максимально сохранить геймплей и дизайн оригинальной игры. А поскольку права на название и изображения персонажей принадлежат компании Snood LLC., я решил переименовать игру в Snooder 21 и перерисовать спрайты главных героев. Новое название, конечно, не блещет оригинальностью, но зато остаётся в духе изначального.
1. Логика игры, отрисовка с помощью канваса класса SurfaceView
2. Обзор методов управления
3. Создание игровых ресурсов
4. Создание лаунчера с таблицей рекордов
5. Заключение, полезные ссылки и ресурсы
Заниматься разработкой игры я решил в этот раз не в Eclipse, а в Android Studio, так как Google уже давно советует перейти именно на эту IDE.
Для отрисовки игрового контекста на экран я использую класс SnoodsSurfaceView, который является наследником системного класса SurfaceView и реализует интерфейсы SurfaceHolder.Callback и Runnable. Отрисовка, а также обсчёт игровой логики и очков происходит в отдельном потоке внутри перегруженного метода run():
@Override public void run() { while (mIsRunning) { tick(); try { mMainCanvas = mSurfaceHolder.lockCanvas(); synchronized (mSurfaceHolder) { render(mMainCanvas); } } finally { if (mMainCanvas != null) { mSurfaceHolder.unlockCanvasAndPost(mMainCanvas); } } } }
Из метода run() вызывается метод tick(), в котором описана основная логика игры:
private void tick() { if (!mIsWinAnimation) { if (mIsDropingCard) { dropCard(); } int locked = 0; for (int i = 0; i < COLUMNS_COUNT; ++i) { if (columnsDecks[i].size() == 5 && columnScores[i] < 21) { columnScores[i] = 21; dropColumn(i, false); } if (columnScores[i] > 21) { lockColumn(i, true); } if (columnScores[i] == 21) { dropColumn(i, false); } if (lockColumns[i]) { locked++; } } if (locked == COLUMNS_COUNT) { mDeckIsEmpty = true; mIsGameOver = true; if (mPlayingGameOverSound) { SnoodsLauncherActivity.playSound(SnoodsLauncherActivity.SOUND_GAME_OVER); mPlayingGameOverSound = false; } } if (!toastShown) { if (mIsGameOver) { snoodsGameActivity.showToast(snoodsGameActivity.getResources().getText(R.string.toast_game_over).toString(), Toast.LENGTH_LONG); toastShown = true; } else if (mDeckIsEmpty) { if (mLevel == 4) { if (getCountOfLockColumns() == 0) { snoodsGameActivity.showToast(snoodsGameActivity.getResources().getText(R.string.toast_congrats).toString(), Toast.LENGTH_LONG); } else { snoodsGameActivity.showToast(snoodsGameActivity.getResources().getText(R.string.toast_game_over).toString(), Toast.LENGTH_LONG); mIsGameOver = true; } } else { snoodsGameActivity.showToast(snoodsGameActivity.getResources().getText(R.string.toast_next_level).toString(), Toast.LENGTH_LONG); } toastShown = true; } } if (mDeckIsEmpty) { if (!columnDropped) { dropAllColumns(); columnDropped = false; } if (allColumnsEmpty()) { if (!mIsGameOver) { mIsWinAnimation = true; SnoodsLauncherActivity.playSound(SnoodsLauncherActivity.SOUND_WIN); } else { snoodsScoreManager.checkHighScore(scores); } SnoodsGameActivity.toDebug("Game End! Game Over: " + mIsGameOver); resetGame(mIsGameOver); } } } else { // mIsWinAnimation == true switch (mLevel) { default: { if (x_anim_sprite % 100 == 0) { showBlinkLabel = !showBlinkLabel; } x_anim_sprite += drop_column_speed; if (x_anim_sprite > ORIGINAL_WIDTH + CARD_HEIGHT + (CARD_WIDTH * 6) + CARD_GAP) { mIsWinAnimation = false; x_anim_sprite = 0; } break; } case 1: { if (y_anim_sprite % 100 == 0) { showBlinkLabel = !showBlinkLabel; } y_anim_sprite += drop_column_speed; if (y_anim_sprite > ORIGINAL_HEIGHT + CARD_HEIGHT + (CARD_HEIGHT * 6) + CARD_GAP) { mIsWinAnimation = false; y_anim_sprite = 0; } break; } } } }
После вызова tick() происходит вызов метода render(), который формирует итоговую картинку и выводит её на канвас, отвечающий непосредственно за вывод изображения на экран:
private void render(Canvas canvas) { if (canvas != null) { if (!mIsWinAnimation) { // Draw background mBitmapCanvas.drawBitmap(mBackGroundBitmap, 0, 0, mMainPaint); // Draw score label mBitmapCanvas.drawBitmap(mLabels[4], 419, 2, mMainPaint); // Draw scores paintNumber(mBitmapCanvas, mMainPaint, scores, 600, 6, false, false); // Draw column scores paintColumnScores(mBitmapCanvas, mMainPaint); // Draw cards count paintNumber(mBitmapCanvas, mMainPaint, cardIndex, 20, 288, false, false); // Draw "L" letter mBitmapCanvas.drawBitmap(mChars[11], 97, 288, mMainPaint); // Draw level paintNumber(mBitmapCanvas, mMainPaint, mLevel, 97 + 29, 288, false, false); // Draw progress bar paintProgressBar(mBitmapCanvas, mMainPaint); // Draw time paintNumber(mBitmapCanvas, mMainPaint, secs, 104, 4, false, true); // Draw card decks drawCardDecs(mBitmapCanvas, mMainPaint); // Draw Highlight highlightColumn(mBitmapCanvas, mMainPaint, highlightColumn); // Draw cards if (cardIndex > 2) { mBitmapCanvas.drawBitmap(mNextCardBitmap, initialCardCoordX, initialCardCoordY - 10, mMainPaint); } if (cardIndex > 1) { mBitmapCanvas.drawBitmap(mNextCardBitmap, initialCardCoordX, initialCardCoordY - 5, mMainPaint); } if (!mDeckIsEmpty) { mBitmapCanvas.drawBitmap(mCurrentCardBitmap, mX_card_coord, mY_card_coord, mMainPaint); } // Draw FPS if (SnoodsSettings.showFps) { mMainPaint.setColor(Color.WHITE); mBitmapCanvas.drawText(getTimesPerSecond() + " fps", 70, 450, mMainPaint); mMainPaint.reset(); } } else { paintWinAnimation(mBitmapCanvas, mMainPaint); } if (SnoodsSettings.antialiasing) { mMainPaint.setFilterBitmap(true); } canvas.drawBitmap(mGameBitmap, mOriginalScreenRect, mOutputScreenRect, mMainPaint); } }
Чтобы не делать наборы спрайтов для разных разрешений дисплеев Android-устройств, я решил рисовать всё на холсте размера 800×480, а потом этот холст растягивать на весь экран со сглаживанием или без. Это несколько неправильный подход, поскольку на слабых устройствах или на девайсах с большим разрешением экрана будет значительно проседать FPS. Эксперименты показали, что FPS проседает в основном из-за сглаживания, поэтому я решил сделать возможность его отключения. Поскольку этот подход позволял сэкономить огромное количество времени, я выбрал именно его.
Колода карт представляет из себя простой массив int[], длина которого изменяется в зависимости от сложности уровня игры. В начало массива добавляется необходимое количество обычных карт, а в конец массива добавляются шесть специальных карт, которые очищают столбец. После чего колода случайным образом тусуется. В методе flushDeck() и происходит вся эта кухня:
private void flushDeck() { cardIndex = 6 + 13 + 13 * ((mLevel == 4) ? 3 : mLevel); mDeck = new int[cardIndex]; // Add cards for (int i = 0, j = 0, k = 0; i < cardIndex; ++i, ++j) { if (j > 12) { j = 0; } mDeck[i] = j; if (i >= cardIndex - 6) { mDeck[i] = 13 + k; // Jokers if (k < 2) { k++; } else { k = 0; } } } String c = ""; for (int i = 0; i < cardIndex; ++i) { c += mDeck[i] + " "; } SnoodsGameActivity.toDebug(c); int changeCard, tempCard; for (int r = 0; r < cardIndex; r++) { changeCard = mRandom.nextInt(cardIndex); tempCard = mDeck[r]; mDeck[r] = mDeck[changeCard]; mDeck[changeCard] = tempCard; } c = ""; for (int i = 0; i < cardIndex; ++i) { c += mDeck[i] + " "; } SnoodsGameActivity.toDebug(c); }
По значениям из массива int[] на игровое поле рендерятся две карты: текущая и следующая. После того, как карта помещена в столбец, текущая карта подменяется следующей, а следующая — уже следующей по значению массива. И так до самого конца игровой колоды:
private void switchToNextCard() { cardIndex--; if (cardIndex > 0) { mCurrentCardBitmap = cardBitmaps[mDeck[cardIndex - 1]]; mCurrentCardBitmapToDeck = cardBitmaps[mDeck[cardIndex - 1] + 17]; if (cardIndex > 1) { mNextCardBitmap = cardBitmaps[mDeck[cardIndex - 2]]; } } else { mDeckIsEmpty = true; } mX_card_coord = initialCardCoordX; mY_card_coord = initialCardCoordY; }
Каждый отдельный столбец представлен как ArrayList<Bitmap>, а все колонки вместе представлены как ArrayList<Bitmap>[]. К примеру, так выглядит метод добавления карты в определённый столбец:
private void addCardToColumn(int column) { columnDropped = false; SnoodsLauncherActivity.playSound(SnoodsLauncherActivity.SOUND_DROP); refreshScores(column); columnsDecks[column].add(mCurrentCardBitmapToDeck); }
А методом drawCardDecs() на экран рендерятся все карты в колонках:
private void drawCardDecs(Canvas canvas, Paint paint) { for (int i = 0; i < COLUMNS_COUNT; ++i) { int listSize = columnsDecks[i].size(); for (int j = 0; j < listSize; j++) { int x = columnRects[i].centerX() - mX_card_grab_coord; int y = columnStartHeight + columnOffsets[i] + j * CARD_GAP; canvas.drawBitmap(columnsDecks[i].get(j), x, y, paint); } } }
После набора 21 очка массив столбца просто очищается методом clear().
Простейшие анимации карт в игре представлены двумя способами. Первый напрямую зависит от количества кадров в секунду и может быть отрегулирован, а второй использует для анимации таймер с обратным отсчётом и зависит от времени. Анимации первого типа используются в основном для передвижения карт и их реализация неприлично проста: в методе tick() происходит изменение контролирующих расположение спрайта переменных-координат. Второй тип анимации используется для переключения спрайтов карт и реализован в классе SnoodsAnimationTimer, наследнике системного класса CountDownTimer со следующими методами:
private void animate(int offset) { for (int i = 0; i < columnsDecks[column].size(); ++i) { columnsDecks[column].set(i, bitmaps[offset + columnsDecksValue[column].get(i)]); } } private void animate(Bitmap bitmap) { for (int i = 0; i < columnsDecks[column].size(); ++i) { columnsDecks[column].set(i, bitmap); } } private void animateFirstFrame() { if (lock || lockColumns[column]) { animate(bitmaps[17 + 16]); } else { animate(17); } } private void animateSecondFrame() { if (lock || lockColumns[column]) { animate(bitmaps[16]); } else { animate(0); } } public int getCountOfLockColumns() { int lockedColumns = 0; for (int i = 0; i < SnoodsSurfaceView.COLUMNS_COUNT; ++i) { if (lockColumns[i]) { lockedColumns++; } } return lockedColumns; } @Override public void onTick(long millisUntilFinished) { if (lock) { if (playSound && getCountOfLockColumns() < 3) { SnoodsLauncherActivity.playSound(SnoodsLauncherActivity.SOUND_LOCK); playSound = false; } SnoodsLauncherActivity.doVibrate(SnoodsLauncherActivity.VIBRATE_SHORT); } if (firstFrame) { animateFirstFrame(); firstFrame = false; } else { animateSecondFrame(); firstFrame = true; } } @Override public void onFinish() { animateSecondFrame(); }
Этот класс реализует простейшее попеременное переключение кадров. Основной игровой таймер тоже является наследником класса CountDownTimer и имплементирован в классе SnoodsGameTimer. После того, как время кончится, метод onFinish() просто вызывает конец игры. Ещё этот класс в методе onTick() контролирует переменную, которая отвечает за правильное отображение индикатора прогресса времени, который рисуется обычным прямоугольником.
@Override public void onTick(long millisUntilFinished) { if (!snoodsSurfaceView.mDeckIsEmpty) { snoodsSurfaceView.progressBarPercent += dec; snoodsSurfaceView.secs = (int) millisUntilFinished / 1000; if (snoodsSurfaceView.secs == 20) { snoodsGameActivity.showToast(snoodsGameActivity.getResources().getText(R.string.toast_hurry_up).toString(), Toast.LENGTH_SHORT); } } }
Поскольку большая часть игровой логики и отрисовки заключена в классе SnoodsSurfaceView, он получился весьма громоздким. Если я найду свободное время, то обязательно сделаю рефакторинг и разобью его на отдельные классы. Примечательно, что вся игровая логика Snooder 21 была написана мной лишь за один вечер.
В Snooder 21 я реализовал три варианта управления, первые два из них используют сенсорный экран, а третий — физическую клавиатуру. Поскольку я являюсь счастливым обладателем смартфонов с QWERTY-клавиатурой, третий метод я реализовал в первую очередь. Для обработки событий нажатий на кнопки достаточно перегрузить метод onKeyDown() в классе SnoodsSurfaceView. Не стоит забывать, что в конструкторе этого класса должен быть вызван метод requestFocus(), в противном случае события клавиатуры будут пролетать мимо.
@Override public boolean onKeyDown(int keyCode, KeyEvent event) { switch (keyCode) { case KeyEvent.KEYCODE_Q: case KeyEvent.KEYCODE_1: { putCardToColumn(1); break; } case KeyEvent.KEYCODE_W: case KeyEvent.KEYCODE_2: { putCardToColumn(2); break; } case KeyEvent.KEYCODE_E: case KeyEvent.KEYCODE_3: { putCardToColumn(3); break; } case KeyEvent.KEYCODE_R: case KeyEvent.KEYCODE_4: { putCardToColumn(4); break; } default: { highlightColumn = 0; break; } } return super.onKeyDown(keyCode, event); }
Карты добавляются в столбец методом putCardToColumn(), который по сути лишь меняет управляющие переменные, а все изменения и добавление карты в колонку обеспечивает метод tick(), который вызывается в другом потоке.
public void putCardToColumn(int column) { if (!mIsDropingCard && !mDeckIsEmpty && !mIsDropingColumn && !mIsWinAnimation) { highlightColumn = column; putCardToColumn(columnRects[column - 1].centerX() - CARD_WIDTH / 2, columnRects[column - 1].centerY() - CARD_HEIGHT / 2); } } public void putCardToColumn(int x, int y) { mX_coord_from = x; mY_coord_from = y; mIsDropingCard = true; }
Метод putCardToColumn() и его перегруженная версия используется и в реализации вариантов сенсорного управления. Первый вариант такого управления обеспечивает перетаскивание карт из колоды на необходимые столбцы, а второй перемещает верхнюю карту колоды в столбец, которого коснулся палец. Всё это поведение реализовано в перегруженном методе onTouchEvent() класса SnoodsGameActivity:
@Override public boolean onTouchEvent(MotionEvent event) { if (!mSnoodsSurfaceView.mIsDropingCard && !mSnoodsSurfaceView.mDeckIsEmpty && !mSnoodsSurfaceView.mIsDropingColumn && !mSnoodsSurfaceView.mIsWinAnimation) { int[] winCoordinates = new int[2]; mSnoodsSurfaceView.getLocationInWindow(winCoordinates); int actionMasked = event.getActionMasked(); int x = convertX(event.getRawX()) - winCoordinates[0]; int y = convertY(event.getRawY()) - winCoordinates[1]; int x_c = x - SnoodsSurfaceView.mX_card_grab_coord; int y_c = y - SnoodsSurfaceView.mY_card_grab_coord; switch (actionMasked) { case MotionEvent.ACTION_DOWN: { mSnoodsSurfaceView.touchInDeckRect(x, y); int inColumnRect = mSnoodsSurfaceView.detectColumn(x, y); if (mSnoodsSurfaceView.mIsGrab) { mSnoodsSurfaceView.setCardCoords(x_c, y_c); } else if (inColumnRect > 0) { mSnoodsSurfaceView.setHighlightColumn(inColumnRect); mSnoodsSurfaceView.putCardToColumn(x_c, y_c); } break; } case MotionEvent.ACTION_MOVE: { if (mSnoodsSurfaceView.mIsGrab) { mSnoodsSurfaceView.setCardCoords(x_c, y_c); mSnoodsSurfaceView.setHighlightColumn(mSnoodsSurfaceView.detectColumn(x, y)); } break; } case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: { mSnoodsSurfaceView.mPlayingGrabSound = true; if (mSnoodsSurfaceView.mIsGrab) { mSnoodsSurfaceView.putCardToColumn(x_c, y_c); } break; } } } return super.onTouchEvent(event); }
Поскольку я использую холст размера 800×480, мне приходится конвертировать все координаты касаний специальными методами:
public static int convertX(float x) { return Math.round(x * SnoodsSurfaceView.ORIGINAL_WIDTH / SnoodsSurfaceView.getmScreenWidth()); } public static int convertY(float y) { return Math.round(y * SnoodsSurfaceView.ORIGINAL_HEIGHT / SnoodsSurfaceView.getmScreenHeight()); }
Для наглядной визуализации доступности столбца я использую простую подсветку полупрозрачным прямоугольником зелёного и красного цветов.
private void highlightColumn(Canvas canvas, Paint paint, int column) { if (column == 0) { return; } else { if (lockColumns[column - 1]) { paint.setColor(Color.parseColor("#77CF5B56")); } else { paint.setColor(Color.parseColor("#7733AF54")); } canvas.drawRect(columnRects[column - 1], paint); paint.reset(); } }
Таким образом, при наведении карты на столбец он окрашивается в зелёный или красный цвет в зависимости от того, можно в него поместить карту или нет.
Управление автоматически отключается при проигрывании различных анимаций передвижений карт, чтобы избежать непредвиденных последствий.
Игровое поле Snooder 21 я попытался сделать максимально похожим на таковое из оригинальной игры и за несколько минут нарисовал его в свободном графическом редакторе GIMP, определив размеры холста в 800×480.
В качестве небольшой пасхалки я оставил векторное изображение Motorola C350 под колодой карт. Именно на этой модели мобильного телефона, которая являлась очень популярной в народе, и была оригинальная игра Snood 21. Это изображение можно увидеть пройдя уровень до конца и использовав последнюю карту.
Фоновая заставка, которая проигрывается при переходе на следующий уровень, тоже была нарисована мною в GIMP’е. На простом градиентном фоне, который символизирует небо, я использовал фильтр Gradient Flare для создания светила и группу из фильтров RGB Noise, Spread и Motion Blur для создания простой травы. Немного приправил тенями и, мне кажется, получилось достаточно симпатично:
В 2011 году Johannes Kopf, являющийся сотрудником компании Microsoft Research и профессор Dani Lischinski опубликовали научный доклад с описанием нового алгоритма депикселизации изображений, который значительно превосходит все существующие методы. Позже, в рамках инициативной программы по развитию проектов с открытым исходным кодом от компании Google — Google Summer of Code 2013, этот алгоритм реализовали в отдельной библиотеке libdepixelize, написаной на языке программирования C++. Позже эту библиотеку включили в состав свободного редактора векторной графики Inkscape.
Благодаря работе этих замечательных учёных и редактору Inkscape у меня появилась возможность нарисовать забавные спрайты-смайлики для игровых карт. Я отрисовывал в GIMP’е пикселизированное изображение небольшого размера, затем импортировал его в Inkscape, использовал там инструмент Trace Pixel Art и получал необходимый спрайт.
Для того, чтобы спрайты в формате PNG весили ещё меньше, я обработал их специальной утилитой optipng, которая доступна в репозиториях большинства дистрибутивов GNU/Linux.
$ optipng -o7 game_sprite.png
Использование этой утилиты позволило уменьшить размер PNG-спрайтов приблизительно на 20%.
Подходящие звуки для игры я долго и упорно отбирал на тематических сайтах freesound и OpenGameArt.Org. Все они были опубликованы под свободными лицензиями, авторов звуковых эффектов я отметил в документе Sounds_Licenses.txt в своём репозитории.
Для того, чтобы было удобно работать с различными опциями, я вынес все настройки в специальный лаунчер, который сохраняет их при выходе из приложения и восстанавливает при повторном запуске. В лаунчере можно определить скорость анимации или задать имя, которое будет использовано для записей в таблице рекордов, которая находится здесь же.
Для законченности я добавил в лаунчер обложку, которую тоже нарисовал в GIMP’е и диалоги About и Help с информацией и правилами игры.
Таблица рекордов образована двумя объектами класса TextView: первый отображает имена, а второй количество набранных очков. Обновление таблицы рекордов осуществляется методом updateHighScoreTable(), который берёт нужную информацию из вложенного класса SnoodsSettings и соединяет всё в строки с переносами, которые и отображают на экране объекты класса TextView:
public static void updateHighScoreTable() { String players = ""; String scores = ""; for (int i = 0; i < HIGH_SCORE_PLAYERS; ++i) { players += SnoodsSettings.playerNames[i]; scores += SnoodsSettings.playerScores[i]; if (i < HIGH_SCORE_PLAYERS - 1) { players += "\n"; scores += "\n"; } } playerNamesView.setText(players); playerScoresView.setText(scores); }
Управлением таблицей рекордов занимается класс SnoodsScoreManager, методы которого выполняют различную работу, например, генерируют уникальное имя игрока при первом запуске игры, или проверяют то, что набранные очки действительно нужно вносить в таблицу. Если игрок выставит некорректное, например, пустое имя, то этот класс заменит его на Player или обрежет до 11 символов.
public static String generatePlayerName() { String modelName = Build.MANUFACTURER.subSequence(0, 3).toString(); modelName = modelName.toUpperCase(Locale.getDefault()); modelName += "-" + Build.MODEL; return normalizePlayerName(modelName); } private static String normalizePlayerName(String name) { if (name.equals("")) { name = "Player"; } if (name.length() > 11) { name = name.subSequence(0, 11).toString(); } return name; } private void saveHiScores() { if (SnoodsLauncherActivity.settingStorage != null) { SharedPreferences.Editor editor = SnoodsLauncherActivity.settingStorage.edit(); for (int i = 0; i < SnoodsLauncherActivity.HIGH_SCORE_PLAYERS; ++i) { editor.putString("player" + i, SnoodsSettings.playerNames[i]); editor.putInt("score" + i, SnoodsSettings.playerScores[i]); } editor.commit(); } else { SnoodsGameActivity.toDebug("Error: settingStorage is null!"); } } private void insertScore(String name, int score, int i) { if (i != -1) { String localObject = SnoodsSettings.playerNames[i]; String str = ""; int j = SnoodsSettings.playerScores[i]; int k = 0; for (int m = i + 1; m < SnoodsLauncherActivity.HIGH_SCORE_PLAYERS; m++) { k = SnoodsSettings.playerScores[m]; str = SnoodsSettings.playerNames[m]; SnoodsSettings.playerScores[m] = j; SnoodsSettings.playerNames[m] = localObject; j = k; localObject = str; } SnoodsSettings.playerNames[i] = name; SnoodsSettings.playerScores[i] = score; saveHiScores(); snoodsGameActivity.runOnUiThread(new Runnable() { @Override public void run() { SnoodsLauncherActivity.updateHighScoreTable(); } }); } } private int getScorePosition(int score) { for (int i = 0; i < SnoodsLauncherActivity.HIGH_SCORE_PLAYERS; ++i) { if (score > SnoodsSettings.playerScores[i]) { return i; } } return -1; } public int checkHighScore(int highScore) { SnoodsGameActivity.toDebug("Score is:" + highScore); int i = getScorePosition(highScore); if (i == -1) { return i; } String answer = snoodsGameActivity.getResources().getText(R.string.toast_high_score_ch1).toString() + " "; answer += highScore + " "; answer += snoodsGameActivity.getResources().getText(R.string.toast_high_score_ch2).toString(); if (SnoodsSettings.writeScores) { answer += " " + snoodsGameActivity.getResources().getText(R.string.toast_high_score_ch3).toString(); insertScore(playerName, highScore, i); } snoodsGameActivity.showToast(answer, Toast.LENGTH_SHORT); return i; }
Метод insertScore() в своём теле запускает обновление таблицы рекордов в интерфейсном потоке. Набранные очки проверяются на рекорд при каждом проигрыше или при нажатии на клавишу Back во время игры.
Создание ремейка игры Snood 21 на Android OS позволило мне разобраться в некоторых тонкостях сенсорного управления, овладеть базовыми навыками работы в мощнейших графических редакторах GIMP и Inkscape, а также использовать Android Studio вместе с системой автоматической сборки Gradle. Этот процесс дал мне огромное количество ценного опыта, я смог реализовать за короткое время весьма интересную карточную игру.
Размер установочного APK-пакета получился небольшим, всего 480 КБ. Основной вес приходится на спрайты и фоны достаточно большого разрешения. Поскольку я использовал только язык программирования Java, приложение должно отлично работать на всех доступных архитектурах процессоров, на которых работает Android OS.
Скачать APK-пакет Snooder 21, готовый для запуска на любом Android-устройстве, можно по этим ссылкам:
[Скачать APK-пакет Snooder 21, 480 КБ | Download Snooder 21 APK-package, 480 КB]
Все исходные коды и проект в целом выложен в репозиторий на ресурсе Github. Мои изменения и исходные файлы доступны под лицензией MIT, а лицензия CC BY 3.0 используется для всех созданных игровых ресурсов. Ссылка на репозиторий:
https://github.com/EXL/Snooder21
В этой работе я использовал огромное количество материалов, основные из них я выделю в полезных ссылках ниже. Огромное спасибо ресурсам stackoverflow.com и google.com за то, что они есть.
Update 06-MAR-2017: Прочитать информацию о новой версии моего ремейка Snooder 21 v1.1 с незначительными исправлениями и улучшениями можно по этой ссылке.
Update 05-MAY-2017: В проекте были обновлены компоненты Gradle, это ознаменовало выход новой версии Snooder 21 v1.2, скачать которую можно со странички проектов.
Update 12-JUL-2017: Пользователь GitHub’а под ником picsi попросил меня добавить игру Snooder 21 в маркет свободных и бесплатных приложений, который называется F-Droid. Я отправил программу на модерацию в специальный репозиторий и спустя три дня мой ремейк Snood 21 был добавлен в каталог этого маркета. Посмотреть страничку приложения можно по этой ссылке.
]]>Впервые я столкнулся с игрой AstroSmash в 2002 или 2003 году на мобильном телефоне Motorola T720. Это был очень дорогой и функциональный девайс, который поддерживал возможность установки и запуска Java-приложений, что было большой редкостью в то время. Среди предустановленных игр на этом телефоне был и ремейк AstroSmash, выполненный на Java ME. Несмотря на своё примитивное оформление и простейший геймплей, игрушка очень сильно затягивала и я часто соревновался с другом в том, кто больше наберёт в ней очков и попадёт в таблицу рекордов.
Ремейк для Motorola T720 тоже получился популярным и Motorola продолжила сотрудничество с компанией THQ, по заказу которой и была разработана Java-игра AstroSmash усилиями программистов из Lavastorm. Позже её переписали на компилируемый язык программирования и выпустили в качестве встроенной в прошивку игры для бюджетных моделей Motorola: C350 и V150.
Правила игры AstroSmash очень простые, игроку необходимо управлять небольшим космическим корабликом с лазерной пушкой и уничтожать пролетающие мимо астероиды и врагов, не дав упасть им на поверхность планеты. Нужно сбить как можно больше объектов и набрать максимальное количество очков. За уничтожение разных врагов выдаётся разное количество очков, а за падение объектов на поверхность и за потери жизней, очки, соответственно, снимаются.
Объект |
Очки |
Большой метеорит |
10 |
Малый метеорит |
20 |
Большой спутник |
40 |
Пульсар |
50 |
Малый спутник |
80 |
НЛО |
100 |
Таблица очков, которые дают за уничтожение определённого объекта в игре AstroSmash.
Пульсары наводятся на ваш кораблик, поэтому необходимо соблюдать осторожность при их появлении на экране. За каждые 1000 очков добавляется ещё одна жизнь, а когда счёт равен или превышает 5000 очков, на экране появляется НЛО, которое атакует кораблик, сбрасывая на него бомбы, которые можно уничтожить. Если большой или малый спутник упадёт на землю, либо метеорит, его осколки или пульсар столкнутся с корабликом, то он разрушается и в итоге сгорает одна жизнь. При расстреле большого метеорита он распадается на два малых. Если столкновение неизбежно, можно использовать прыжок в гиперпространство, это моментально переместит кораблик в случайно выбранную позицию на экране. При желании можно включить или выключить автоматический огонь.
Недавно мой давний друг напомнил мне об этой замечательной игре за разговором и у меня появилась идея сделать ремейк мобильной версии для современных смартфонов под управлением Android OS. Восстанавливать логику AstroSmash и писать собственный движок не очень хотелось, хоть он и был примитивным, и я решил поискать в интернете какую-либо информацию, касательно этой игры. Мои поиски увенчались успехом, на каком-то польском файлообменном сайте я нашёл архив со стандартными Java-играми для Motorola T720, внутри которого был и MIDlet интересующей меня игры, из которого теоретически можно было достать необходимую логику и ресурсы. Итак, распаковав загруженный архив, я приступил к изучению JAR-файла AstroSmash.
1. Декомпиляция MIDlet’а с помощью программы Java Decompiler
2. Обзор декомпилированного Java-кода
3. Создание заготовки SurfaceView и сопряжение декомпилированного кода с ней
4. Логические ошибки декомпилированного Java-кода
5. Отрисовка и реализация сенсорного управления
6. Создание лаунчера с таблицей рекордов
7. Дополнительные возможности
8. Заключение, полезные ссылки и ресурсы
JAR-файл представляет собой обычный ZIP-архив, который можно открыть любым архиватором, чем я и воспользовался. Все игровые файлы с данными были доступны в открытом виде и для всех спрайтов использовался формат изображений PNG, так что готовые к использованию ресурсы достаточно было просто извлечь из JAR-архива. Поскольку AstroSmash является проприетарной игрой с закрытым исходным кодом, мне пришлось воспользоваться специальной программой-декомпилятором Java-кода — Java Decompiler, чтобы посмотреть, как устроена логика игры и её движок. Для декомпиляции MIDlet’а достаточно открыть JAR-файл через главное меню программы или перенести его в главное окно.
Декомпилированный Java-код получился весьма читаемым, сохранились все названия классов и их переменных. Автоматически сгенерированными были лишь локальные аргументы методов, названия которых выглядели как paramLong, paramInt, paramString, то есть они именовались по типу или классу, которому принадлежал этот параметр-аргумент. Чтобы сохранить все декомпилированные классы в Java Decompiler необходимо выбрать опцию File -> Save All Sources, которая создаст обычный ZIP-архив.
Для изучения декомпилированного исходного кода его можно импортировать в любую IDE, поддерживающую Java, например, Eclipse IDE. Для этого просто создаём новый Java-проект и помещаем всё содержимое сохранённого архива в каталог src/.
Поскольку классы Java ME в стандартной поставке JDK отсутствуют, в исходниках будет куча ошибок при импорте Java ME пакетов, которые будут мешать изучению кода. Чтобы избавиться от них я подключил к проекту библиотеку из пакета MicroEmulator. MicroEmulator позволяет эмулировать прослойку Java ME на различных устройствах, например, на десктопном компьютере. К сожалению, у меня так и не получилось добиться эмуляции AstroSmash, хотя другие MIDlet’ы с помощью MicroEmulator весьма неплохо работали. Но поскольку эмуляция мне не требовалась, я ограничился лишь использованием одной из множества библиотек эмулятора, которая реализовывала подсистему MIDP 2.0. Эту библиотеку можно подключить в настройках проекта: в разделе Java Build Path следует выбрать вкладку Libraries, нажать кнопку Add External JARs… и выбрать файл midpapi20.jar из поставки MicroEmulator, после чего все ошибки, связанные с импортом библиотек Java ME исчезнут.
Декомпиляция Java-классов прошла не совсем успешно, и несколько ошибок компиляции всё-таки всплыло. Все они были связаны с неправильным определением класса или опусканием типа объявляемой переменной и были исправлены простейшим патчем:
diff --git a/src/com/lavastorm/HiscoreFile.java b/src/com/lavastorm/HiscoreFile.java index 6aef4d0..c9a9a27 100644 --- a/src/com/lavastorm/HiscoreFile.java +++ b/src/com/lavastorm/HiscoreFile.java @@ -193,7 +193,7 @@ public class HiscoreFile if (i == -1) { return -1; } - Object localObject = this.m_names[i]; + String localObject = this.m_names[i]; String str = null; int j = this.m_scores[i]; int k = 0; diff --git a/src/com/lavastorm/astrosmash/GameWorld.java b/src/com/lavastorm/astrosmash/GameWorld.java index 951d8f3..8157006 100644 --- a/src/com/lavastorm/astrosmash/GameWorld.java +++ b/src/com/lavastorm/astrosmash/GameWorld.java @@ -178,7 +178,7 @@ public class GameWorld this.m_vecFlyingEnemies.removeElementAt(j); this.m_enemyFactory.putEnemy(localEnemy); } - for (i = this.m_vecFlyingBullets.size(); i > 0; i = this.m_vecFlyingBullets.size()) + for (int i = this.m_vecFlyingBullets.size(); i > 0; i = this.m_vecFlyingBullets.size()) { j = i - 1; Collidable localCollidable = (Collidable)this.m_vecFlyingBullets.elementAt(j); diff --git a/src/com/lavastorm/astrosmash/HiScoreDisplay.java b/src/com/lavastorm/astrosmash/HiScoreDisplay.java index d111bda..9206854 100644 --- a/src/com/lavastorm/astrosmash/HiScoreDisplay.java +++ b/src/com/lavastorm/astrosmash/HiScoreDisplay.java @@ -130,7 +130,7 @@ public class HiScoreDisplay localGraphics.setColor(AstrosmashVersion.BLACKCOLOR); for (int k = 0; k < 5; k++) { - localObject = ""; + String localObject = ""; if (this.m_highlightPosition == k) { localObject = ">"; }
После которого никаких неполадок не осталось и можно было приступить к изучению декомпилированного исходного кода.
Любой MIDlet для мобильного телефона это набор определённых классов и игра AstroSmash не является исключением. Главный класс, из которого стартует движок игры, называется AstrosmashMidlet и наследуется от системного класса MIDlet. В этом классе реализована различная рутина, связанная с загрузкой игровых ресурсов, отображением пунктов главного меню игры и некоторые вспомогательные статические методы, связанные с генерацией псевдослучайных чисел. Приблизительным аналогом этого класса в Android OS является класс, отнаследованный от Activity. Некоторые методы и конструктор из AstrosmashMidlet:
public AstrosmashMidlet() { m_random = new Random(System.currentTimeMillis()); this.m_statusCanvas = null; this.m_command1 = null; this.m_command2 = null; this.m_display = Display.getDisplay(this); InfoStrings.initializeInfo(); this.m_gauge = showGauge(); this.m_timer = new Timer(); } protected void setupAstroSmashScreen() { if (this.m_astroSmashScreen == null) { this.m_astroSmashScreen = new AstrosmashScreen(this.m_pause, this.m_next, this.m_quit, this.m_blank); } this.m_astroSmashScreen.setCommandListener(this); } public void startApp() { if (this.m_bGameStarted) { startGame(false); } else { scheduleCommand(this.m_initialize, this.m_gauge, 0L); } } public void pauseApp() { this.m_astroSmashScreen.pause(); } public void destroyApp(boolean paramBoolean) throws MIDletStateChangeException { this.m_astroSmashScreen.exit(); this.m_timer.cancel(); } protected void startGame(boolean paramBoolean) { if ((paramBoolean) || (!this.m_bGameStarted)) { this.m_astroSmashScreen.resetStartTime(); this.m_astroSmashScreen.restartGame(); } this.m_display.setCurrent(this.m_astroSmashScreen); this.m_astroSmashScreen.setCommandListener(this); this.m_astroSmashScreen.start(); this.m_bGameStarted = true; } public static int getRandomInt() { m_random.setSeed(System.currentTimeMillis() + m_random.nextInt()); return m_random.nextInt(); } public static int getAbsRandomInt() { m_random.setSeed(System.currentTimeMillis() + m_random.nextInt()); return Math.abs(m_random.nextInt()); }
В AstrosmashMidlet создаётся объект класса AstrosmashScreen, родителем которого является системный класс Canvas и реализует интерфейс Runnable. В этом классе в отдельном потоке создаётся главный цикл игры с объектом игрового мира и происходит обработка различных событий, к примеру, нажатий на кнопки мобильного телефона. Приблизительным аналогом этого класса в Android OS является наследник системного класса SurfaceView. Конструктор AstrosmashScreen и некоторые из его методов выглядят следующим образом:
public AstrosmashScreen(Command paramCommand1, Command paramCommand2, Command paramCommand3, Command paramCommand4) { ... if (this.m_gameWorld == null) { this.m_gameWorld = new GameWorld(getWidth(), getHeight() - AstrosmashVersion.getCommandHeightPixels(), this); } } protected void paint(Graphics paramGraphics) { if (this.m_bFirstPaint) { clearScreen(paramGraphics); this.m_bFirstPaint = false; } this.m_gameWorld.paint(paramGraphics); } public void pause() { this.m_bRunning = false; this.m_gameThread = null; this.m_gameWorld.pause(true); repaint(); serviceRepaints(); System.gc(); } public void exit() { this.m_bRunning = false; this.m_gameThread = null; } public void start() { this.m_bRunning = true; this.m_gameThread = new Thread(this); this.m_gameThread.start(); this.m_gameWorld.pause(false); } public void run() { try { long l1 = System.currentTimeMillis(); long l2 = System.currentTimeMillis(); long l5 = l2; this.m_nLastMemoryUsageTime = 0L; while (this.m_bRunning) { long l3 = System.currentTimeMillis(); long l4 = l3 - l2; if ((this.m_bKeyHeldDown) && (l3 - this.m_initialHoldDownTime > 250L) && (l3 - l5 > 75L)) { l5 = l3; this.m_gameWorld.handleAction(this.m_heldDownGameAction); } this.m_gameWorld.tick(l4); repaint(); serviceRepaints(); l2 = l3; } } catch (Exception localException) { System.out.println(localException.getMessage()); localException.printStackTrace(); } } protected void keyPressed(int paramInt) { try { int i = getGameAction(paramInt); if (this.m_bRunning) { this.m_gameWorld.handleAction(i); } this.m_heldDownGameAction = i; this.m_bKeyHeldDown = true; this.m_initialHoldDownTime = System.currentTimeMillis(); } catch (Exception localException) { System.out.println(localException.getMessage()); localException.printStackTrace(); } } protected void keyReleased(int paramInt) { this.m_bKeyHeldDown = false; }
Игровым движком является объект класса GameWorld, в нём выполняется обработка поступающих игровых событий, коллизий, поведения противников и пуль, отрисовка готового кадра на канвас. Внутри класса создаётся масса различных объектов, управлением которых занимается главный метод tick(), а метод paint() формирует игровую картинку и выводит её на экран.
public void paint(Graphics paramGraphics) { this.m_backgroundManager.paint(paramGraphics); this.m_ship.paint(paramGraphics); paintFlyingBullets(paramGraphics); paintEnemies(paramGraphics); if (this.m_bGamePaused) { paintMessage(paramGraphics, InfoStrings.GAME_PAUSED_STRING, null); } else if (this.m_bGameOver) { paintMessage(paramGraphics, InfoStrings.GAME_OVER_STRING, InfoStrings.PEAK_SCORE_STRING + ": " + this.m_nPeakScore); } if ((AstrosmashVersion.getDebugFlag()) && (AstrosmashVersion.getDebugFpsFlag())) { drawFPS(paramGraphics); } } public void tick(long paramLong) { if ((!this.m_ship.getCollided()) && (!this.m_bGameOver)) { tickBullets(paramLong); tickEnemies(paramLong); if ((this.m_bAutoFire) && (this.m_nTimeSinceLastFire > 333L)) { fireBullet(); this.m_nTimeSinceLastFire = 0L; } else { this.m_nTimeSinceLastFire += paramLong; } checkLevel(); } }
Движимые игровые объекты являются экземплярами различных классов, иерархия которых выглядит следующим образом:
Drawable │ └── Collidable │ └── Enemy │ ├── GunShip ├── Ufo └── SwappableEnemy │ └── Pulser
Формированием и уничтожением этих объектов во время игры занимаются специальные классы-фабрики: EnemyFactory и MunitionsFactory. За создание игрового фона и интерфейса отвечают классы BackgroundManager и StarManager. В большинстве подобных классов присутствуют метод tick() для управления и метод paint() для отрисовки объекта на канвас.
public class Ufo extends Enemy { private int m_nFireInterval = 0; private long m_timeSinceFired = 0L; public void setFireInterval(int paramInt) { this.m_nFireInterval = paramInt; } public void tick(long paramLong, GameWorld paramGameWorld) { this.m_timeSinceFired += paramLong; if (this.m_timeSinceFired > this.m_nFireInterval) { paramGameWorld.fireUfoBullet(getCenterX(), getCenterY()); this.m_timeSinceFired = 0L; } super.tick(paramLong, paramGameWorld); } }
Некоторые классы реализуют интерфейсы IDeathListener и IGameWorldListener, которые отвечают за своевременный вызов методов doneExploding() и gameIsOver() соответственно. Имеется ещё несколько вспомогательных классов, реализующих разные игровые экраны, подсчёт количества очков и таблицу рекордов, строки с локализацией и настройки игровых параметров в зависимости от мобильного устройства, которое определяется весьма интересным образом: по разрешению экрана, на который установлена игра.
private static int getHeight() { GlobalsCanvas localGlobalsCanvas = new GlobalsCanvas(); System.err.println("height:" + localGlobalsCanvas.getHeight()); return localGlobalsCanvas.getHeight(); } private static int getWidth() { GlobalsCanvas localGlobalsCanvas = new GlobalsCanvas(); System.err.println("width:" + localGlobalsCanvas.getWidth()); return localGlobalsCanvas.getWidth(); } static { int i = getWidth(); int j = getHeight(); platform = -1; switch (i + j * 1024) { case 101496: platform = 5; DEVICE = "LG 5350"; break; case 149624: platform = 6; DEVICE = "Motorola 720i"; break; } }
После того, как я получил небольшое общее представление о том, как работает AstroSmash, я приступил к созданию приложения для Android OS, на основе полученных знаний.
Создав простое Android-приложение, я дополнил его двумя классами: AstroSmashActivity, наследником Activity, и AstroSmashView, отнаследованным от SurfaceView и реализующим интерфейсы SurfaceHolder.Callback и Runnable. Получилась вот такая заготовка, которую теперь можно было соединить с декомпилированными классами AstroSmash:
public class AstroSmashView extends SurfaceView implements SurfaceHolder.Callback, Runnable { private boolean m_bRunning = true; private Thread m_gameThread = null; private int screenWidth; private int screenHeight; private int coordX; private int coordY; private Paint painter; private Canvas canvas; private SurfaceHolder surfaceHolder = null; public AstroSmashView(Context context) { super(context); surfaceHolder = getHolder(); surfaceHolder.addCallback(this); painter = new Paint(); } public void render(Canvas canvas) { if (canvas != null) { canvas.drawColor(Color.LTGRAY); painter.setColor(Color.YELLOW); canvas.drawRect(coordX, coordY, coordX + 20, coordY + 20, painter); } } public void exit() { this.m_bRunning = false; this.m_gameThread = null; } public void start() { this.m_bRunning = true; this.m_gameThread = new Thread(this); this.m_gameThread.start(); } public void tick() { coordX += 1; coordY = 20; } @Override public void surfaceCreated(SurfaceHolder holder) { AstroSmashActivity.toDebug("Surface created"); screenWidth = holder.getSurfaceFrame().width(); screenHeight = holder.getSurfaceFrame().height(); start(); } @Override public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { AstroSmashActivity.toDebug("Surface changed: " + width + "x" + height + "|" + screenWidth + "x" + screenHeight); } @Override public void surfaceDestroyed(SurfaceHolder holder) { boolean shutdown = false; this.m_bRunning = false; while (!shutdown) { try { if (m_gameThread != null) { this.m_gameThread.join(); } shutdown = true; } catch (InterruptedException e) { AstroSmashActivity.toDebug("Error joining to Game Thread"); } } } @Override public void run() { while (this.m_bRunning) { tick(); try { this.canvas = surfaceHolder.lockCanvas(); synchronized (surfaceHolder) { render(this.canvas); } } finally { if (this.canvas != null) { surfaceHolder.unlockCanvasAndPost(this.canvas); } } } } }
Я выделил движок игры и его классы в отдельный подкаталог AstroSmashEngine/. Множество декомпилированных классов были просто лишними, поэтому я решил разрешить все зависимости класса игрового мира GameWorld, добавляя ему всё необходимое. Таким образом, отпала надобность в некоторых классах AstroSmash, функциональность которых я реализую позже. AstrosmashMidlet и AstrosmashScreen были заменены на AstroSmashActivity и AstroSmashView соответственно.
Движок AstroSmash использовал стандартные API и классы Java ME, которые, конечно, были недоступны в Android OS. Поэтому мне пришлось заменить их аналогами. Так, класс Image был заменён на класс Bitmap, а класс Graphics — на связку из классов Canvas и Paint. Ресурсы для класса Bitmap, а в моём случае это спрайты в формате PNG, необходимо было разместить в каталоге res/drawable-nodpi/, чтобы они не масштабировались в зависимости от DPI экрана Android-смартфона. Помимо этого, для объектов Bitmap пришлось пробрасывать вниз по цепочке конструкторов экземпляр класса Context, чтобы иметь возможность загружать изображения из ресурсов в необходимых классах таким методом:
this.m_image = BitmapFactory.decodeResource(context.getResources(), resourceID);
Класс Graphics в Java ME очень похож на таковой из JDK, но в Android OS отрисовку нужно выполнять с помощью классов Canvas и Paint. Методы Graphics и Canvas несколько различаются, но весьма похожи по смыслу, к примеру, вместо drawImage() следует использовать drawBitmap(), а вместо drawString() необходимо вызывать drawText(). Кроме этого следует помнить о координатах, они тоже немного различаются, например, если в метод drawText() передать начальные координаты X и Y равные нулю, то текст будет отрисовываться за экраном, это же поведение справедливо и для отрисовки Bitmap’ов.
После всех выполненных мной преобразований, мне удалось избавиться от ошибок и собрать APK-пакет, который я запустил на Android-эмуляторе. Примечательно, что всё заработало с первого раза и приложение отобразило мне уменьшенный игровой контекст. Я пробросил обработку аппаратных кнопок в AstrosmashScreen и попробовал немного поиграть, но ничего путного из этого не получилось: при любом игровом столкновении, даже когда я стрелял и попадал по врагу, мой кораблик разрушался и я терял одну жизнь.
Стало очевидно, что просчёт коллизий в движке работает неправильно, и его необходимо поправить.
Ещё во время изучения декомпилированного кода игры я заметил странный метод intersects() в классе Collidable:
public boolean intersects(Collidable paramCollidable, int paramInt1, int paramInt2) { int i = getX(); int j = i + paramInt1 * getWidth(); int k = getY(); int m = k + paramInt2 * getHeight(); int n = paramCollidable.getX(); int i1 = n + paramCollidable.getWidth(); int i2 = paramCollidable.getY(); int i3 = i2 + paramCollidable.getHeight(); if (((n >= i) && (n < j)) || ((i1 >= i) && (i1 < j)) || ((i >= n) && (i < i1)) || ((j >= n) && (j < i1) && (((i2 >= k) && (i2 < m)) || ((i3 >= k) && (i3 < m)) || ((k >= i2) && (k < i3)) || ((m >= i2) && (m < i3))))) { setCollided(true); paramCollidable.setCollided(true); return true; } return false; }
Больше всего меня удивило длиннющее логическое выражение в нём. Почему-то оно всегда возвращало true и кораблик думал, что в него что-то врезалось и погибал. Я переписал этот код так:
public boolean intersects(Collidable paramCollidable, int paramInt1, int paramInt2) { Rect rectIn = new Rect(getX(), getY(), getX() + getWidth(), getY() + getHeight()); Rect rectOut = new Rect( paramCollidable.getX(), paramCollidable.getY(), paramCollidable.getX() + paramCollidable.getWidth(), paramCollidable.getY() + paramCollidable.getHeight()); if (rectIn.intersect(rectOut)) { setCollided(true); paramCollidable.setCollided(true); return true; } return false; }
И всё чудесным образом заработало! Видимо Java-декомпилятор не справился со сложным логическим условием и где-то допустил ошибку. К слову, исходный код метода intersect() класса Rect, выглядит вот так:
public boolean intersect(int left, int top, int right, int bottom) { if (this.left < right && left < this.right && this.top < bottom && top < this.bottom) { if (this.left < left) this.left = left; if (this.top < top) this.top = top; if (this.right > right) this.right = right; if (this.bottom > bottom) this.bottom = bottom; return true; } return false; }
К сожалению, это была не единственная логическая ошибка декомпиляции, вторым моим разочарованием было отсутствие отрисовки звёзд в игре. Над этой ошибкой я долго бился, а причина оказалась банальной: неправильно задавался цвет маркера и звёзды рисовались чёрным цветом на чёрном небе. Оказалось, что декомпилятор развернул все константы в int вида 16777215 и при вызове метода:
public static final int WHITE_COLOR = 16777215; ... paint.setColor(WHITE_COLOR);
Вместо белого цвета устанавливался чёрный, поскольку холст был формата ARGB_8888. Эту проблему я решил с помощью класса Color, который возвращал правильное значение цвета:
public static final int WHITE_COLOR = Color.WHITE;
Подобным образом я исправил остальные проблемы со цветами и звёзды зажглись на небе! Третья отловленная ошибка была забавной: когда игру ставили на паузу, она просто вылетала. Дело оказалось вот в чём: в движке есть метод, который отрисовывает сразу две текстовых строки на экране, верхнюю и нижнюю:
protected void paintMessage(Canvas canvas, Paint paint, String paramString1, String paramString2) { paint.getTextBounds(paramString1, 0, paramString1.length(), textBounds); int i = textBounds.height() + 5; // gap = 5 pixels int r = textBounds.width(); paint.getTextBounds(paramString2, 0, paramString2.length(), textBounds); int r2 = textBounds.width(); // WTF?! // if (AstroSmashVersion.getPlatform() == 6) { // paint.setColor(AstroSmashVersion.BLACKCOLOR); // } else { // paint.setColor(AstroSmashVersion.WHITECOLOR); // } paint.setColor(Version.WHITECOLOR); // Draw message on center screen canvas.drawText(paramString1, this.m_nScreenWidth / 2 - r / 2, this.m_nScreenHeight / 2, paint); if ((paramString2 != null) && (!paramString2.equals(""))) { canvas.drawText(paramString2, this.m_nScreenWidth / 2 - r2 / 2, this.m_nScreenHeight / 2 + i, paint); } }
Когда игра окончена, в верхней строке отображается надпись GAME OVER а в нижней — количество набранных очков. Когда игрок вызывает паузу, в верхней строке отображается GAME PAUSED, а нижняя строка остаётся пустой. Так вот декомпилятор вместо пустой строки подставил туда null и вызов метода для отрисовки сообщения паузы стал выглядеть так:
paintMessage(canvas, paint, InfoStrings.GAME_PAUSED_STRING, null);
Естественно, что вызов любого метода относительно null сразу бросает исключение. Я поправил код таким образом:
paintMessage(canvas, paint, InfoStrings.GAME_PAUSED_STRING, "");
После чего игра перестала падать.
Добившись нормальной работы движка игры я решил заняться отображением игрового конетекста на весь экран и сенсорным управлением. Растянуть картинку не представляет особой сложности, нужно лишь изменить аргументы метода drawBitmap() таким образом, чтобы он имел представление о параметрах двух прямоугольников: об исходном и о том, который должен получиться в итоге. Исходным прямоугольником является тот маленький отображаемый игровой контекст, а результирующим — прямоугольник с размерами экрана современного Android-устройства. Всё это можно приправить сглаживанием по желанию, чтобы растянутая картинка не так сильно резала глаз.
canvas.drawBitmap(gameScreenBitmap, bitmapRect, screenRect, painter);
Модификацией метода drawBitmap() я добился необходимого и картинка растянулась так, как и следовало ожидать.
Теперь пришло время задуматься о сенсорном MultiTouch управлении. Я решил разбить экран на три части: нижняя часть должна отвечать за передвижение кораблика, верхняя часть экрана за приостановку игры на паузу, а центральная, самая большая, за стрельбу, когда отключена функция автоматического огня. Кораблик должен следовать за движением пальца по нижней части экрана и стрелять, когда нажимают в центр. Для реализации такого поведения я перегрузил метод onTouchEvent() в классе AstroSmashActivity и наполнил его следующим кодом:
@Override public boolean onTouchEvent(MotionEvent event) { int touchId = event.getPointerCount() - 1; if (touchId < 0) { return false; } int action = event.getActionMasked(); switch(action) { case MotionEvent.ACTION_DOWN: if (!astroSmashView.isM_bRunning()) { AstroSmashActivity.toDebug("Restart Game"); if (astroSmashView.checkHiScores(RESTART_GAME_YES) == -1) { if (astroSmashView.isGameOver()) { astroSmashView.SetisGameOver(false); astroSmashView.restartGame(false); } } } else if (event.getY(touchId) < astroSmashView.getScreenHeightPercent()) { paused = !paused; astroSmashView.pause(paused); } else if (event.getY(touchId) < astroSmashView.getScreenRectChunkProcent() && event.getY(touchId) > astroSmashView.getScreenHeightPercent()) { astroSmashView.fire(); } break; case MotionEvent.ACTION_POINTER_DOWN: if (event.getY(touchId) < astroSmashView.getScreenRectChunkProcent() && event.getY(touchId) > astroSmashView.getScreenHeightPercent()) { astroSmashView.fire(); } break; case MotionEvent.ACTION_MOVE: if (!paused) { if (event.getY(touchId) > astroSmashView.getScreenRectChunkProcent()) { astroSmashView.setShipX(convertCoordX(event.getX(touchId))); } } break; } return true; }
Приватный метод convertCoordX() выполняет преобразования между координатами исходного и результирующего прямоугольников и выглядит следующим образом:
private int convertCoordX(float xCoord) { return Math.round(xCoord * Version.getWidth() / astroSmashView.getScreenWidth()); }
Получилось весьма неплохо, но обнаружилась маленькая проблемка: палец, который управлял корабликом, полностью закрывал его. Я решил в нижней части контекста отрисовать прямоугольник со стрелкой в 15% от высоты экрана. Простенькую стрелку было решено рисовать процедурно.
В общем счёте, метод render() и его вспомогательные методы получились такими:
public void render(Canvas canvas) { if (canvas != null && bitmapCanvas != null) { if (this.m_bFirstPaint) { clearScreen(bitmapCanvas); this.m_bFirstPaint = false; } this.m_gameWorld.paint(bitmapCanvas, painter); if (gameScreen != null) { if (AstroSmashSettings.antialiasing) { painter.setFilterBitmap(true); } if (AstroSmashSettings.showTouchRect) { canvas.drawBitmap(gameScreen, bitmapRect, screenRectPercent, painter); drawTouchArrow(canvas, painter); } else { canvas.drawBitmap(gameScreen, bitmapRect, screenRect, painter); } } } } private int px(float dips) { float dp = getResources().getDisplayMetrics().density; return Math.round(dips * dp); } private void drawTouchArrow(Canvas canvas, Paint paint) { paint.setColor(Version.GREENCOLOR_DARK); canvas.drawRect(0, screenRectChunkProcent, screenWidth, screenHeight, paint); paint.setStrokeCap(Cap.ROUND); paint.setAntiAlias(true); for (int i = 0; i < 2; ++i) { if (i == 0) { paint.setColor(Version.GRAYCOLOR); paint.setStrokeWidth(px5); } else { paint.setColor(Version.DARKCOLOR); paint.setStrokeWidth(px1); } canvas.drawLine(px25, arrow_Y0, arrow_X0, arrow_Y0, paint); canvas.drawLine(px25, arrow_Y0, px25double, arrow_Y1, paint); canvas.drawLine(px25, arrow_Y0, px25double, arrow_Y2, paint); canvas.drawLine(arrow_X0, arrow_Y0, arrow_X1, arrow_Y1, paint); canvas.drawLine(arrow_X0, arrow_Y0, arrow_X1, arrow_Y2, paint); } } private int getPercentChunkHeight(int sideSize, int percent) { return Math.round(sideSize * percent / 100); } private void init() { screenHeightPercent = getPercentChunkHeight(screenHeight, 15); touchRect = new Rect(0, screenHeight - screenHeightPercent, screenWidth, screenHeight); bitmapRect = new Rect(0, 0, gameScreen.getWidth(), gameScreen.getHeight()); screenRect = new Rect(0, 0, screenWidth, screenHeight); screenRectPercent = new Rect(0, 0, screenWidth, screenHeight - screenHeightPercent); px1 = px(1); px5 = px(5); px25 = px(25); px25double = px25 * 2; arrow_Y0 = screenHeight - touchRect.height() / 2; arrow_Y1 = screenHeight - touchRect.height() / 4; arrow_Y2 = screenHeight - touchRect.height() / 4 * 3; arrow_X0 = screenWidth - px25; arrow_X1 = screenWidth - px25double; screenRectChunkProcent = screenHeight - screenHeightPercent; }
Поскольку теперь палец перестал перекрывать кораблик, прицеливаться во врагов стало гораздо удобнее.
Вместо игровых экранов с настройками и счётом, которые были в MIDlet’e, я решил создать лаунчер к игре и разместить все настройки там, так как их накопилось довольно много. Кроме того, в лаунчер я вынес таблицу с рекордами. Счёт и все параметры сохраняются во внутреннем хранилище игры с помощью класса SharedPreferences и извлекаются после запуска лаунчера AstroSmash. А когда игрок нажимает кнопку Run AstroSmash!, все параметры с формы отправляются в игровой движок. Для реализации лаунчера я создал класс AstroSmashLauncher и унаследовал его от Activity, затем добавил в него всю необходимую рутину, занимающуюся чтением настроек с формы, их сохранением и заполнением формы из настроек.
Когда игрок в AstroSmash набирает рекордный счёт и завершает игру, появляется диалог, предлагающий ввести ему своё имя и сохранить счёт. Вариант предлагаемого имени автоматически генерируется в зависимости от производителя и модели устройства.
Этот диалог реализован посредством специального класса AstroSmashHighScoreDialog, отнаследованного от Activity, который использует системную тему диалога, выставленную в файле AndroidManifest.xml. В зависимости от нажатой в диалоге кнопки, результат заносится в таблицу или игнорируется. Реализация класса следующая:
public class AstroSmashHighScoreDialog extends Activity { private int score; private int index; private int restart; private Button buttonOk = null; private Button buttonCancel = null; private EditText playerName = null; private TextView scoreView = null; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); score = getIntent().getIntExtra("peakScore", 0); index = getIntent().getIntExtra("indexScore", -1); restart = getIntent().getIntExtra("restartGame", 0); setContentView(R.layout.activity_dialoghiscore); setTitle(getResources().getText(R.string.app_name).toString() + getResources().getText(R.string.GameOver).toString()); scoreView = (TextView) findViewById(R.id.textViewScore); scoreView.setText(getResources().getText(R.string.Score).toString() + score); playerName = (EditText) findViewById(R.id.editTextPlayerName); String modelName = Build.MANUFACTURER.subSequence(0, 3).toString(); modelName = modelName.toUpperCase(Locale.getDefault()); modelName += "-" + Build.MODEL; AstroSmashActivity.toDebug(modelName); playerName.setText(modelName); buttonOk = (Button) findViewById(R.id.buttonOk); buttonOk.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { String name = playerName.getText().toString(); name = name.trim(); if (name.equals("")) { name = "Player"; } if (name.length() > 11) { name = name.subSequence(0, 11).toString(); } insertScore(name, score, index); finish(); } }); buttonCancel = (Button) findViewById(R.id.buttonCancel); buttonCancel.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { finish(); } }); } private void insertScore(String name, int score, int i) { if (i != -1) { String localObject = AstroSmashSettings.playerNames[i]; String str = null; int j = AstroSmashSettings.playerScores[i]; int k = 0; for (int m = i + 1; m < AstroSmashLauncher.HISCORE_PLAYERS; m++) { k = AstroSmashSettings.playerScores[m]; str = AstroSmashSettings.playerNames[m]; AstroSmashSettings.playerScores[m] = j; AstroSmashSettings.playerNames[m] = localObject; j = k; localObject = str; } AstroSmashSettings.playerNames[i] = name; AstroSmashSettings.playerScores[i] = score; saveHiScores(); AstroSmashLauncher.updateGameTable(); } } public static void saveHiScores() { if (AstroSmashLauncher.settingsStorage != null) { SharedPreferences.Editor editor = AstroSmashLauncher.settingsStorage.edit(); for (int i = 0; i < AstroSmashLauncher.HISCORE_PLAYERS; ++i) { editor.putString("player" + i, AstroSmashSettings.playerNames[i]); editor.putInt("score" + i, AstroSmashSettings.playerScores[i]); } editor.commit(); } else { AstroSmashActivity.toDebug("Error: settingsStorage is null!"); } } @Override protected void onDestroy() { if (restart == AstroSmashActivity.RESTART_GAME_YES) { if (AstroSmashActivity.getAstroSmashView() != null) { AstroSmashActivity.getAstroSmashView().restartGame(false); } } super.onDestroy(); } }
Вызов диалога и проверка того, что был получен рекордный счёт, происходит в классе AstroSmashView в методе checkHiScores():
public int getScorePosition(int score) { for (int i = 0; i < AstroSmashLauncher.HISCORE_PLAYERS; i++) { if (score > AstroSmashSettings.playerScores[i]) { return i; } } return -1; } public int addScore(int score, int restartGame) { int i = getScorePosition(score); if (i == -1) { return i; } Intent intent = new Intent(this.getContext(), AstroSmashHighScoreDialog.class); intent.putExtra("peakScore", score); intent.putExtra("indexScore", i); intent.putExtra("restartGame", restartGame); astroSmashActivity.startActivity(intent); return i; } public int checkHiScores(int restartGame) { int peakScore = m_gameWorld.getPeakScore(); AstroSmashActivity.toDebug("HiScore is: " + peakScore); return addScore(peakScore, restartGame); }
Обмен данными между экземплярами классов-наследников Activity реализован с помощью вызовов методов putExtra() и getIntExtra().
В качестве небольшого украшения и для завершённости я вставил в лаунчер изображения обложек игры AstroSmash, которые шли в ресурсах MIDlet’a и реализовал диалоги About и Help с краткой справкой по игре и различной информацией об авторах и разработчиках, работавших над созданием AstroSmash.
Благодаря созданию лаунчера я отказался от использования классов MIDlet’а, которые работали с канвасом и которые было бы трудно перенести и адаптировать под Android OS.
Оригинальная игра AstroSmash, выпущенная для Intellivision имела звуковые эффекты, а её Java-версия для мобильных телефонов почему-то получилась полностью немая. Я решил исправить этот значительный недочёт и добавить в игру звуки и виброотдачу. Для этого я создал статические методы doVibrate(), playSound() и playGameOverSound() в классе AstroSmashLauncher:
public static void doVibrate(int duration) { if (AstroSmashSettings.vibro) { vibrator.vibrate(duration); } } public static void playSound(int soundID) { if (AstroSmashSettings.sound && (soundID != 0)) { final int SOUND_ID = soundID; new Thread(new Runnable() { @Override public void run() { soundPool.play(SOUND_ID, 1.0f, 1.0f, 0, 0, 1.0f); } }).start(); } } public static void playGameOverSound() { if (AstroSmashSettings.sound) { new Thread(new Runnable() { @Override public void run() { toneGenerator.startTone(ToneGenerator.TONE_CDMA_PRESSHOLDKEY_LITE); } }).start(); } }
В случае с playSound() проигрывается обычный звук в формате OGG из каталога res/raw/ приложения, а в случае с playGameOverSound() используется встроенный системный тоногенератор. Инициализация звука, вибромотора и тоногенератора производится в методе onCreate():
protected void onCreate(Bundle savedInstanceState) { vibrator = (Vibrator) getSystemService(Context.VIBRATOR_SERVICE); if (AstroSmashSettings.sound) { soundPool = new SoundPool(5, AudioManager.STREAM_MUSIC, 0); toneGenerator = new ToneGenerator(AudioManager.STREAM_MUSIC, ToneGenerator.MAX_VOLUME); SOUND_HIT = soundPool.load(this, R.raw.s_hit, 1); SOUND_UFO = soundPool.load(this, R.raw.s_ufo, 1); SOUND_SHIP = soundPool.load(this, R.raw.s_ship, 1); SOUND_SHOT = soundPool.load(this, R.raw.s_shot, 1); } ... }
Для проигрывания звука и виброотдачи в нужный момент, я просто расставил эти методы внутри движка в местах расчёта коллизий, разрушения игрового кораблика и сигнала конца игры. Кстати, интересный момент: звуки в формате WAV выводились с ощутимой задержкой, поэтому пришлось конвертировать их в OGG.
По умолчанию звёзды в игре одного цвета — белые. Я решил сделать опцию, позволяющую генерировать звёзды случайно выбранного видимого цвета из спектральной классификация звёзд. Для этого я создал массив цветов, заполнил его таким образом, чтобы белый цвет был преобладающим и реализовал вспомогательные методы:
int[] colorArray = { Color.parseColor("#9AAFFF"), Color.parseColor("#CAD7FF"), Color.parseColor("#F8F7FF"), Color.parseColor("#F8F7FF"), Color.parseColor("#F8F7FF"), Color.parseColor("#F8F7FF"), Color.parseColor("#F8F7FF"), Color.parseColor("#FFF2A1"), Color.parseColor("#FFE46F"), Color.parseColor("#FFA040") }; private int getRandomStarColor() { return colorArray[AstroSmashView.getRandomIntBetween(0, colorArray.length)]; } public static int getRandomIntBetween(int min, int max) { return (int) ((Math.random() * (max - min)) + min); } public void generateStarImage(int color) { bitmapCanvas.drawColor(color); for (int i = 0; i < this.m_numStars; i++) { if (AstroSmashSettings.colorizeStars) { bitmapPaint.setColor(getRandomStarColor()); } else { bitmapPaint.setColor(Version.WHITECOLOR); } bitmapCanvas.drawPoint(this.m_xPos[i], this.m_yPos[i], bitmapPaint); } }
Теперь звёздное небо в игре стало выглядеть гораздо красивее и реалистичнее:
Локализация игры уже была заложена в самом MIDlet’е, мне осталось лишь продолжить начатое. Я добавил методы, возвращающие локализованные строки на русском языке в класс InfoStrings и поправил определение текущей локали Android OS.
Затем я скопировал файл res/values/strings.xml в каталог res/values-ru/ и перевёл в нём все строки. Игра отлично подхватила системную локаль Android OS с русским языком.
Портирование игры AstroSmash с Java ME на Android OS позволило мне разобраться в некоторых тонкостях работы с декомпилированным Java-кодом и научиться создавать приложения для Android OS, использующие в качестве блиттера канвас класса SurfaceView. Этот процесс дал мне огромное количество ценного опыта, я смог сделать интересную реализацию сенсорного управления и научился локализовывать приложения.
Размер установочного APK-пакета получился весьма небольшим, всего лишь 114 КБ. Поскольку я использовал только язык программирования Java, приложение должно отлично работать на всех доступных архитектурах процессоров, на которых работает Android OS.
Скачать APK-пакет AstroSmash, готовый для запуска на любом Android-устройстве, можно по этим ссылкам:
[Скачать APK-пакет AstroSmash, 114 КБ | Download AstroSmash APK-package, 114 КB]
Все исходные коды и проект в целом выложен в репозиторий на ресурсе Github. Мои изменения и исходные файлы доступны под лицензией MIT. Ссылка на репозиторий:
https://github.com/EXL/AstroSmash
В этой работе я использовал огромное количество материалов, основные из них я выделю в полезных ссылках ниже. Огромное спасибо ресурсам stackoverflow.com и google.com за то, что они есть.
Update 06-MAR-2017: Со мной связался главный разработчик оригинальной игры Astrosmash на Motorola T720 — Albert So и положительно оценил мой труд. Большое спасибо ему за это! Прочитать информацию о новой версии AstroSmash v1.1 с незначительными исправлениями и улучшениями можно по этой ссылке.
Update 05-MAY-2017: Проект был переведён с устаревших технологий ant и Eclipse ADT на Gradle и Android Studio. Это ознаменовало выход новой версии AstroSmash v1.2, скачать которую можно со странички проектов.
]]>