В этой статье я хочу поделиться своим опытом создания первой игры для платформы Android, рассказать весь путь от зарождения идеи до публикации.
Предыстория
С 2012 года я вплотную стал заниматься программированием, в частности разработкой программ под Windows на языке C#. За всё время я получил большой опыт разработки .NET приложений, участвовал в различных проектах, в том числе и командных. Так вышло, что мне захотелось заняться разработкой под Android. Передо мной стоял выбор C# (Xamarin) или Java. Так как я последние 3 года программировал на C# и хотелось, как можно быстрей приступить к разработке, а не разбираться в новой IDE и нюансах программирования на Java выбор был очевиден.
Идея «что разрабатывать?» родилась быстро и заключалось в разработке приложения для прослушивания и скачивания музыки в ВК. Приложение было почти завершено, но в связи с некоторыми проблемами так и не опубликовано. Спустя полгода, у меня появилось уйма «свободного» времени и я решил вернуться к разработке приложений под Android. Познакомившись с одним программистом и пообщавшись с ним о преимуществах и недостатках Java (Android) и C# (Xamarin), я начал под его руководством штудировать книги по Java и осваивать IDE IntelliJ IDEA.
Идея, или с чего всё началось
После двух недель ознакомления с принципами разработки (которые давались мне довольно легко) знакомый дизайнер предложил вместо чтения книг и повторения примеров приступить сразу к разработке игры, а возможные проблемы, связанные с программированием, решать по мере их поступления. Именно так я и поступил, оставалось лишь придумать идею для игры, но уже через 5 минут её придумал сам дизайнер.
Игра направлена на развитие памяти и её суть заключается в следующем: игроку в течение нескольких секунд демонстрируются изображение с несколькими фигурами разной формы, цвета, размера и расположения, которые он должен успеть запомнить, после чего повторить. За каждый пройденный уровень игрок получает «звездочки», которые позволяют открыть новые более сложные уровни.
Для определения оценки (количества полученных звездочек) пройденного уровня было решено использовать 3 параметра: цвет фигуры, её местоположение и радиус. Для добавления сложности в игровой процесс было введено условие, что игроку необходимо повторить формы всех представленных фигур, с учётом их расположения на экране, иначе уровень будет считаться непройденным.
Дизайн
С разработкой графического интерфейса мне помог всё тот же дизайнер, который уже не первый раз участвовал в разработке UI/UX дизайн для мобильных приложений. Он разработал логотип, иконку, выбрал основные цвета, шрифты, размеры кнопок и др. Затем представил готовое решение интерфейса, нарисованное в векторе. Подготовил материалы для релиза («превьюшки», рекламные изображения и т.д.).
Результаты работы можно увидеть на скриншотах:
Разработка
После получения готового дизайна игры я сразу принялся за разработку. Сложности, с которыми пришлось столкнуться, были связаны лишь с реализацией рисования фигур. Всего нужно было реализовать 6 методов для рисования фигур (круг, квадрат, треугольник, пятиугольник, звезда, шестиугольник), два из которых уже реализованы в Java. Так же одним из условий было то, что фигура должна отрисовываться от центра во все стороны, и должна быть вписанной в окружность с радиусом, размер которого определял уже сам пользователь. Поскольку это был мой первый подобный проект, я решил самостоятельно реализовать методы рисования оставшихся четырех фигур, а не использовать готовые библиотеки. Для рисования фигур использовался canvas.drawPath() (метод рисования линий), мне оставалось только определить точки, через которые должны проходить путь линии. В связи с этим пришлось вспомнить школьную программу и открыть учебник по геометрии.
Следующим нюансом было то, что фигура может быть отрисована несколькими способами: с обозначенным центром, с рамкой, без рамки и центра, с рамкой и центром, при этом рамка может быть сплошной либо пунктирной. Для решения этой проблемы был создан интерфейс с тремя методами (рисование фигуры, рамки, рисование фигуры с рамкой). Данный интерфейс был использован при реализации классов рисования для фигур.
Интерфейс для рисования фигуры
public interface DrawFigure {
public void draw(Canvas canvas, PointF startPoint, PointF finishPoint, Paint mPaint);
public void drawWithBorder(Canvas canvas, PointF startPoint, PointF finishPoint, Paint mPaint, Paint borderPaint);
public void drawBorder(Canvas canvas, PointF startPoint, PointF finishPoint, Paint mPaint, Paint borderPaint);
}
Класс рисования треугольника
public class DrawThreeAngle implements DrawFigure {
private float mRadius;
private int[] angles = new int[]{30, 150, 270, 30};
private float[] xValues = new float[4];
private float[] yValues = new float[4];
@Override
public void draw(Canvas canvas, PointF startPoint, PointF finishPoint, Paint mPaint) {
mRadius = Tools.getRadius(startPoint, finishPoint);
Path threeanglePath = new Path();
for (int i = 0; i < angles.length; i++) {
xValues[i] = startPoint.x + (float) (mRadius * Math.cos(Math.toRadians(angles[i])));
yValues[i] = startPoint.y + (float) (mRadius * Math.sin(Math.toRadians(angles[i])));
if (i==0){
threeanglePath.moveTo(xValues[i], yValues[i]);
}
else {
threeanglePath.lineTo(xValues[i], yValues[i]);
}
}
threeanglePath.close();
canvas.drawPath(threeanglePath, mPaint);
}
@Override
public void drawWithBorder(Canvas canvas, PointF startPoint, PointF finishPoint, Paint mPaint, Paint borderPaint) {
draw(canvas, startPoint, finishPoint, mPaint);
drawBorder(canvas, startPoint, finishPoint, mPaint, borderPaint);
}
@Override
public void drawBorder(Canvas canvas, PointF startPoint, PointF finishPoint, Paint mPaint, Paint borderPaint) {
for (int i = 0; i < angles.length - 1; i++) {
canvas.drawLine(xValues[i], yValues[i], xValues[i + 1], yValues[i + 1], borderPaint);
}
}
}
Класс рисования звезды
public class DrawStar implements DrawFigure {
private float mRadius;
private int[] angles = new int[]{-18, -54, 270, 234, 198, 162, 126, 90, 54, 18, -18};
private float[] xValues = new float[11];
private float[] yValues = new float[11];
@Override
public void draw(Canvas canvas, PointF startPoint, PointF finishPoint, Paint mPaint) {
mRadius = Tools.getRadius(startPoint, finishPoint);
Path starPath = new Path();
for (int i = 0; i < angles.length; i++) {
if (i % 2 == 0) {
xValues[i] = startPoint.x + (float) (mRadius * Math.cos(Math.toRadians(angles[i])));
yValues[i] = startPoint.y + (float) (mRadius * Math.sin(Math.toRadians(angles[i])));
} else {
xValues[i] = startPoint.x + (float) (mRadius / 2 * Math.cos(Math.toRadians(angles[i])));
yValues[i] = startPoint.y + (float) (mRadius / 2 * Math.sin(Math.toRadians(angles[i])));
}
if (i==0){
starPath.moveTo(xValues[i], yValues[i]);
}
else {
starPath.lineTo(xValues[i], yValues[i]);
}
}
starPath.close();
canvas.drawPath(starPath, mPaint);
}
@Override
public void drawWithBorder(Canvas canvas, PointF startPoint, PointF finishPoint, Paint mPaint, Paint borderPaint) {
draw(canvas, startPoint, finishPoint, mPaint);
drawBorder(canvas, startPoint, finishPoint, mPaint, borderPaint);
}
@Override
public void drawBorder(Canvas canvas, PointF startPoint, PointF finishPoint, Paint mPaint, Paint borderPaint) {
for (int i = 0; i < angles.length - 1; i++) {
canvas.drawLine(xValues[i], yValues[i], xValues[i + 1], yValues[i + 1], borderPaint);
}
}
}
Реализация уровней
После реализации всего игрового процесса, я принялся за реализацию уровней. Всего было создано 60 уровней различной сложности. Сложность уровней зависит от количества фигур, их размеров и расположения. Так же сложность уровней повышалась за счёт возможности рисования пресекающихся фигур.
Для хранения всех уровней была создана база данных, состоящая из двух таблиц со списком. Так как фигуры в базе данных заданы координатами (центр фигуры и точка на её радиусе), то на разных экранах они будут отображаться в разном масштабе или вообще выходить за пределы. Поэтому появилась необходимость в решении проблемы отрисовки уровня в одинаковом масштабе для разных экранов. Для решения этой проблемы были определены координаты фигур при так называемом эталонном разрешении. А отображение в нужном масштабе осуществляется за счет определения коэффициента отношения эталонного разрешения к текущему разрешению и умножения координат всех фигур на рассчитанный коэффициент.
Структура базы данных
public class LevelsDatabaseHelper extends SQLiteOpenHelper {
private static final String DB_NAME = "levels.sqlite";
private static final int VERSION = 1;
private static final String TABLE_LEVELS = "levels";
private static final String COLUMN_LEVELS_STARS = "stars";
private static final String COLUMN_LEVELS_TIME = "time";
private static final String COLUMN_LEVELS_NUMBER = "number";
private static final String TABLE_FIGURE = "figure";
private static final String COLUMN_FIGURE_TYPE = "type";
private static final String COLUMN_FIGURE_NUMBER = "number";
private static final String COLUMN_FIGURE_CENTER_X = "center_x";
private static final String COLUMN_FIGURE_CENTER_Y = "center_y";
private static final String COLUMN_FIGURE_FINISH_X = "finish_x";
private static final String COLUMN_FIGURE_FINISH_Y = "finish_y";
private static final String COLUMN_FIGURE_COLOR = "color";
private static final String COLUMN_FIGURE_LAYER_INDEX = "layer_index";
private Context mContext;
private SQLiteDatabase mDataBase;
public LevelsDatabaseHelper(Context context) {
super(context, DB_NAME, null, 2);
}
@Override
public void onCreate(SQLiteDatabase db) {
mDataBase = db;
db.execSQL("create table " + TABLE_LEVELS + " (_id integer primary key autoincrement, " +
COLUMN_LEVELS_NUMBER + " integer, " + COLUMN_LEVELS_STARS + " integer, " + " string, " + COLUMN_LEVELS_TIME + " integer)");
db.execSQL("create table " + TABLE_FIGURE + " (_id integer primary key autoincrement, " + COLUMN_FIGURE_NUMBER +
" integer, " + COLUMN_FIGURE_TYPE + " string, " + COLUMN_FIGURE_CENTER_X + " real, "
+ COLUMN_FIGURE_CENTER_Y + " real, " + COLUMN_FIGURE_FINISH_X + " real, " + COLUMN_FIGURE_FINISH_Y
+ " real, " + COLUMN_FIGURE_COLOR + " integer, " + COLUMN_FIGURE_LAYER_INDEX + " string)");
}
}
Жульничество
При тестировании уровней был найдена лазейка при прохождении уровней, в которых есть пересекающиеся фигуры. Лазейка заключалась в том, что при проверке уровня не учитывается слой, на котором находится фигура. В итоге можно нарисовать сначала верхнюю фигуру, а затем нижнюю и уровень всё равно считался бы пройденным. Для устранения этой лазейки в класс фигуры был добавлена переменная, определяющая номер слоя, на котором она расположена. Так же были учтены следующие условия:
- Фигура не пересекается с другими, т.е. уровень слоя ей не важен, в этом случае введенная переменная принимает значение «-1»;
- На одном слое расположено больше одной фигуры, в этом случае переменная принимает номера слоев через «/», на котором она может быть расположена («2/3/4»).
Более наглядно решение проблемы представлено на изображении:
При проверке уровня сначала выполнялась проверка фигур, независящих от слоя, а после сравнивались номера слоёв оставшихся фигур в порядке, в котором их отрисовали.
Custom View
Учитывая нетривиальный дизайн, большинство View пришлось реализовывать самостоятельно, например прогресс бар в окне результата пройденного уровня. Прогресс бар имеет нестандартный внешний вид, два заголовка слева (имя параметра, по которому производится оценка) и справа (полученный результат по данному параметру). Так же одним из условий, было возможность поддержки анимированного заполнения.
Внешний вид Progress Bar
Код ProgressBarSuccessView
public class ProgressBarSuccessView extends View {
float mPercentValue = 0;
String mLeftTitleText = "title";
int mWidth, mHeight;
final float mLeftTitleSize = 0.3f, mProgressBarSize = 0.5f, mRightTitleSize = 0.2f;
float mBorderMargin, mProgressMargin;
RectF mProgressBarBorder = new RectF(), mProgressBarRectangle = new RectF();
int mLeftTitleX, mLeftTitleY, mRightTitleX, mRightTitleY;
Paint mTitlePaint, mProgressPaint, mBorderProgressBarPaint;
private Rect mTitleTextBounds = new Rect();
public ProgressBarSuccessView(Context context) {
super(context);
init(null);
}
public ProgressBarSuccessView(Context context, AttributeSet attrs) {
super(context, attrs);
init(attrs);
}
public ProgressBarSuccessView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
init(attrs);
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
mWidth = w;
mHeight = h;
mTitlePaint.getTextBounds(mLeftTitleText, 0, mLeftTitleText.length(), mTitleTextBounds);
mLeftTitleX = (int) ((w * mLeftTitleSize - mTitleTextBounds.width()) / 2 - mTitleTextBounds.left);
mLeftTitleY = (h - mTitleTextBounds.height()) / 2 - mTitleTextBounds.top;
mProgressBarBorder.set(w * mLeftTitleSize, 0 + mBorderMargin, w * (mLeftTitleSize + mProgressBarSize), h - mBorderMargin);
}
private void init(AttributeSet attrs) {
TypedArray a = getContext().obtainStyledAttributes(attrs, R.styleable.ProgressBarTitle);
mLeftTitleText = a.getString(R.styleable.ProgressBarTitle_progress_bar_title);
mBorderMargin = getResources().getDimension(R.dimen.border_progress_bar_margin);
mProgressMargin = getResources().getDimension(R.dimen.progress_bar_margin);
mTitlePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mTitlePaint.setColor(getResources().getColor(R.color.title_level_success_view));
mTitlePaint.setStyle(Paint.Style.STROKE);
mTitlePaint.setTextSize(getResources().getDimension(R.dimen.progress_bar_title_text_size));
mTitlePaint.setTypeface(FontManager.BEBAS_REGULAR);
mBorderProgressBarPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mBorderProgressBarPaint.setColor(getResources().getColor(R.color.title_level_success_view));
mBorderProgressBarPaint.setStyle(Paint.Style.STROKE);
mBorderProgressBarPaint.setStrokeWidth(2);
mProgressPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mProgressPaint.setColor(getResources().getColor(R.color.progress_bar_level_success_view));
mProgressPaint.setStyle(Paint.Style.FILL);
setWillNotDraw(false);
}
public void setPercent(float percent) {
mPercentValue = Math.round(percent * 100f) / 100f;
invalidate();
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
float left, top, right, bottom;
left = mWidth * mLeftTitleSize + mProgressMargin - mBorderMargin;
top = 0 + mProgressMargin;
right = mPercentValue > 0 ? left + mWidth * mProgressBarSize * mPercentValue - mProgressMargin - mBorderMargin : left;
bottom = mHeight - mProgressMargin;
mProgressBarRectangle.set(left, top, right, bottom);
canvas.drawText(mLeftTitleText, mLeftTitleX, mLeftTitleY, mTitlePaint);
canvas.drawRect(mProgressBarBorder, mBorderProgressBarPaint);
canvas.drawRect(mProgressBarRectangle, mProgressPaint);
mTitlePaint.getTextBounds(String.valueOf((int) (mPercentValue * 100)), 0, String.valueOf((int) (mPercentValue * 100)).length(), mTitleTextBounds);
mRightTitleX = (int) (mWidth - mTitleTextBounds.width() / 2 - mWidth * mRightTitleSize / 2);
mRightTitleY = (mHeight - mTitleTextBounds.height()) / 2 - mTitleTextBounds.top;
canvas.drawText(String.valueOf((int) (mPercentValue * 100)), mRightTitleX, mRightTitleY, mTitlePaint);
}
}
Анимация заполнения прогресс бара
public static AnimatorSet createProgressAnimationSet(ArrayList<View> views, float[] percentValues)
{
AnimatorSet animatorSet = new AnimatorSet();
List<Animator> animatorList = new ArrayList<>(views.size());
for (int i = 0; i < views.size(); i++){
float value = percentValues[i];
View view = views.get(i);
animatorList.add(createProgressAnimation(view, value));
}
animatorSet.playTogether(animatorList);
return animatorSet;
}
private static ObjectAnimator createProgressAnimation(View view, float progressValue){
ObjectAnimator shapeProgressAnimator = ObjectAnimator.ofFloat(view, "percent", 0, progressValue);
shapeProgressAnimator.setDuration((long) (1000 * progressValue));
shapeProgressAnimator.setInterpolator(new DecelerateInterpolator());
return shapeProgressAnimator;
}
Подсказки
Чтобы облегчить жизнь игроку, были добавлены подсказки, которые можно получить за очки. Очки, в свою очередь, начисляются за прохождение уровней, количество очков зависит звезд полученных за уровень. Полученные очки игрок может потратить на любую из 3 доступных подсказок: показать фигуру, её центр или окружность радиуса фигуры.
Результаты работы
Итог
Выводы о популярности приложения делать ещё рано, поскольку оно было выложено совсем недавно. По некоторым причинам мне некогда было вплотную заняться продвижением игры. Единственное, что было сделано – опубликованы ссылок на игру в соц. сетях.
На освоение Java я потратил совсем мало времени. А на чтение литературы лучше не растрачиваться, т.к. новых книг нет, а в существующих книгах часто приводятся реализации, которые уже не имеют актуальности в современной разработке мобильных приложений.
Неизвестно, станет ли эта игра хитом или окажется провалом. Сделав эту игру, я получил хороший опыт разработки приложений для Android-устройств, который пригодится мне в будущем, а так же наконец-то занялся тем, чем давно хотел. В настоящее время я разрабатываю свое следующее приложения, подходя к процессу более основательно с учётом своих предыдущих ошибок. Кроме того у меня еще много идей мобильных приложений, которые в скором времени увидит мир.
Игра в Google Play
Обсуждение игры на 4pda.ru
Комментарии (13)
knagaev
30.12.2015 09:49+1Красный кружок с зеленой рамкой — хрестоматийный случай несовместимости цветов в дизайне.
Вот в прогресс-барах сделали правильно, добавили между ними жёлтую прослойку.
Попробуйте сделать так же в кружке, будет смотреться лучше.
Yoto
Либо у меня с глазами проблема, либо вы где-то пиксель забыли. Снизу красного прямоугольника 1 пикселя недостает.
Однако судя по коду, вроде как нигде не потеряли, хотя могу и ошибаться. Люди добрые, если найдете, где ошибка, подскажите.
Во мне умирает перфекционист