Привет всем! Сегодня я хочу рассказать историю создания одной игрушки для Android. Что будет:
- Зачем ещё одна змейка для андроида?! Объяснение (c описанием);
- Как я это сделал — использованные приемы, немного кода;
- Немного о дизайне;
- Google Play Games, нестандарное использование.
Всех заинтересовавшихся прошу под кат!
Зачем ещё одна змейка для андроида?!
Так случилось, что осенью мы с моей девушкой отдыхали на море. Теплая вода, штиль, плавание — все это располагает к рождению разных мыслей в голове. Одной из таких мыслей было «Что делать в самолете на обратном пути?». Отдых был в самом разгаре, и я немного скучал по программированию. Значит, решено — кодить! На чем? Так на 7" Nexus же недавно AIDE установил. Что? Игру, простую. Но — с изюминкой. Так родилась идея написать Змейку с шестиугольными тайлами — не банально, интересно.
Сразу встал вопрос выбора технологий. Прошлую свою игру (Pacman) я писал на С++ + OpenGL ES 2.0, здесь же это явный оверхед. Что-то краем уха слышал о Canvas, немного почитал и понял — то что нужно, дайте два. 4.5 часа в самолете — готов движок, змейка ходит, врезается сама в себя, и реагирует на свайпы в шести направлениях. А потом… потом был еще примерно месяц допиливаний.
Что получилось в итоге:
На скриншотах: Главный экран, Лабиринт «Мельница»(темный скин), Лабиринт «Телепорт» (светлый скин)
- 2 вида игры — лабиринты и классическая;
- 3 скина;
- 7 уровней сложности, открывающихся один за другим.
Бонусом пошел примитивный редактор карт с возможностью тут же запустить игру и попробовать, а так же куча нового опыта.
Как я это сделал
Начнем по порядку.
Главный экран
Он же экран «паузы», с другой раскладкой компонентов.
На этом экране обратим внимание на фон. Я потратил на него целый вечер, и все равно мне иногда кажется, что он выглядит немного не так, как я хочу. Идея сделать его именно таким была частично взята у гугла, частично у 2Gis — нравятся мне эти пересекающиеся широкие лучи. Фон векторный, то есть он занимает в apk считанные байты. Рисуется он на Canvas, в кастомном вью.
public class BackgroundView extends View {
/* конструкторы опущены*/
private Bitmap mBmp;
@Override
protected void onDraw(Canvas canvas) {
/* Кешируем фон, потому что рисовать его при каждой перерисовке слишком накладно*/
if(mBmp == null){
mBmp = Bitmap.createBitmap(canvas.getWidth(), canvas.getHeight(), Config.ARGB_8888);
Canvas c = new Canvas(mBmp);
drawBackground(c);
}
canvas.drawBitmap(mBmp, 0, 0, null);
}
private void drawBackground(Canvas canvas){
canvas.drawRGB(180, 220, 70); /* В этом классе очень много магических констант, подбиравшихся на ходу*/
/* Здесь при помощи Path рисуем несколько широких лучей, разными оттенками зеленого, с разной прозрачностью*/
}
}
Что мы выносим из этого кода? Способ первый (и самый простой ) рисования на Canvas. Наследуемся от View
, переопределяем метод void onDraw(Canvas canvas)
и рисуем на Canvas все, что душе угодно. Именно этим способом отрисовывался мой первый прототип, написанный в самолете. Для динамичного обновления надо вызвать метод invalidate()
у View
, после чего когда-нибудь в будущем вызовется onDraw(). Без гарантий. Более правильный способ получения Canvas для интенсивного рисования описан ниже.
Экран настроек
Тут, в принципе, ничего особенного нет, за исключением превью игры. Кажется, это довольно спорное решение, но мне хотелось, чтобы при смене скина игрок мог сразу увидеть, что его ждет. В дальнейшем количество скинов планирую увеличить. Так же, на устройствах, поддерживающих вибрацию, на этом экране можно включить/выключить ее.
Экран выбора Лабиринта
Пока в игре не слишком много лабиринтов — 8. В дальнейшем планирую добавлять их. В каждой строке есть иконка лабиринта, его название, личный и мировой рекорды. В случае, когда игрок не залогинен в Google Play Games, его личный рекорд автоматически становится мировым. Лабиринты открываются последовательно, как и уровни сложности. И вот тут, пожалуй, настал момент рассказать о нестандартном способе использования Google Play Games…
Нестандартное использование Google Play Games
А точнее, подсистемы достижений. Я назвал это share-over-achievements. Наверняка не я первый это придумал, но описаний такого способа я не находил. Не бейте ногами=)
Когда в игре появляются некие «разблокировки» — например, открытие уровня или лабиринта, как в моем случае, сразу встает вопрос — а что, если у игрока два девайса? Смартфон и планшет, например? Как дать ему использовать контент, разблокированный на одном девайсе, везде? Если у вас есть сервер, и время на его поддержку, то все в ажуре, можно шарить через него. А если, как у меня, ни того, ни другого, то может подойти следующий трюк:
В момент, когда пользователь разблокировал некоторый контент, разблокируйте ачивмент.
Games.Achievements.unlock(getApiClient(), achievementName);
А когда нужно проверить контент на открытость, проверьте заодно эту ачивку (можно заранее).
PendingResult<LoadAchievementsResult> pendingResult = Games.Achievements.load(getApiClient(), true);
pendingResult.setResultCallback(new LoadAchievementsResultCallback());
При этом загруженные достижения лучше кешировать где-то, откуда вы быстро сможете достать их. А так же кешировать разблокированные достижения, в случае, если игрок не залогинен — нужно будет разблокировать их в тот момент, когда он все-таки залогинится.
Почему это нестандартный способ использования Google Play Games? Потому что документация в основном показывает нам, что достижения — это такая свистелка для поднятия настроения пользователя. Но они могут быть еще и реально полезны. И я считаю, это круто. Данный способ я использую для разблокировки лабиринтов и уровней сложности.
Теперь поговорим о самой игре
Поле игры квадратное, 15х15 гексагонов. Логически представлено двумерным массивом тайлов, каждый из которых имеет:
- Координаты «следующего за ним» тайла — используется в теле змейки и телепортах;
- Флаг «смертельности» — опять же, тело змейки и стены лабиринтов;
- Тип тайла — еда, супер еда, змея, стена, телепорт, пусто;
Таким образом, проверка на смерть равна проверка одного флага, перемещение через телепорт не затратнее просто перемещения. Отрисовывается поле при помощи классов, реализующих интерфейс IDrawer
, поэтому количество скинов может увеличиваться со временем.
Выше я описывал самый простой способ отрисовки на Canvas. Более правильный способ для динамичной отрисовки заключается в наследовании от SurfaceView
и отрисовке в отдельном потоке. Остановлюсь на главных моментах:
Наследуемся от SurfaceView
, реализуем SurfaceHolder.Callback
для получения событий SurfaceView
:
public class GameView extends SurfaceView implements SurfaceHolder.Callback{
public GameView(Context context){
super(context);
getHolder().addCallback(this);
}
@Override
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
}
@Override
public void surfaceCreated(SurfaceHolder holder) {
}
@Override
public void surfaceDestroyed(SurfaceHolder holder) {
}
}
Пишем поток отрисовки DrawThread
:
public class DrawThread extends Thread {
private boolean mRunning = false;
private SurfaceHolder mSurfaceHolder;
public DrawThread(SurfaceHolder surfaceHolder) {
mSurfaceHolder = surfaceHolder;
}
public void setRunning(boolean running) {
mRunning = running;
}
@Override
public void run() {
Canvas canvas;
while (mRunning) {
canvas = null;
try {
canvas = mSurfaceHolder.lockCanvas(null);
if (canvas == null){
continue;
}
/* Рисуем на canvas */
} finally {
if (canvas != null) {
mSurfaceHolder.unlockCanvasAndPost(canvas);
}
}
try{
//let other threads do they work
Thread.sleep(15);
}catch(InterruptedException e){
}
}
}
}
Возвращаемся в наш SurfaceView
и стартуем поток отрисовки при создании Surface:
@Override
public void surfaceCreated(SurfaceHolder holder) {
/* Тут можно провести инициализацию механизмов отрисовки, Canvas получать так же, как в потоке отрисовки */
mDrawThread = new DrawThread(holder);
mDrawThread.setRunning(true);
mDrawThread.start();
}
И не забываем убрать за собой при разрушении Surface:
@Override
public void surfaceDestroyed(SurfaceHolder holder) {
destroy();
}
/* Может возникнуть проблема, что surfaceDestroyed() не вызывается сразу, тогда можно дернуть destroy() "руками" */
public synchronized void destroy(){
if(mDrawThread == null){
return;
}
boolean retry = true;
mDrawThread.setRunning(false);
while (retry){
try{
mDrawThread.join();
retry = false;
}
catch (InterruptedException ignored){
}
}
mDrawThread = null;
}
В боевом коде IDrawer
и Game
я передаю в GameView
. В потоке отрисовки происходит так же и пересчет логики.
Выводы
Написание небольших игр (пусть даже и клонов), а так же собственных неигровых проектов чрезвычайно полезно. Особенно полезен выход из зоны комфорта, он позволяет в короткие сроки узнать достаточно много нового и полезного. Из маленького проектика на несколько часов можно сделать вполне интересную игру.
Технология рисования на Canvas вполне подходит для небольших игр, где не нужны особые спецэффекты.
Писать игры на Java под Android гораздо проще, чем на С++, особенно если ты одиночка.
Всем хороших выходных, и с наступающим Новым годом!
Автор: zagayevskiy