Как написать игру под Android за 15 дней. История создания Mega Flood-It. Часть вторая
от aNNiMON
Продолжение статьи о разработке игры. Начало здесь.
День 8
День рефакторинга. Пока что код GameActivity занимал не очень много места, но с добавлением различных режимов он мог возрастать в разы. Поэтому я создал абстрактный класс GameType, в котором была общая для всех режимов логика, а всё остальное реализовывалось в классах-наследниках: LevelType — режим прохождения уровня и RandomType — режим случайной игры.
Очередная сложность, которую предстояло решить — сохранение состояний. Если во время игры сменить тему, уровень начнётся заново. Поэтому нужно сохранить всё, что можно, начиная от параметров игры (seed, размер доски, количество цветов), заканчивая цветами клеток в доске.
А ещё нужно было уже прикручивать статистику. Пока что сделал вывод текстом в Toast:
Ещё почитал про векторную анимацию иконок, хотел прикрутить при смене темы, но не вышло. Оставил на потом.
День 9
О векторной анимации я читал вчера не зря. Пора бы уже добавить иконки и сделать игровое меню со сменой темы и рестартом. В то время в Android Studio как раз добавили повсеместную поддержку VectorDrawables и можно было не рисовать кучу иконок разных размеров. Это ускорило разработку.
Дальше у меня появилась идея для конечной заставки, оповещающей об успешном прохождении уровня или о проигрыше. Просто статичный экран с надписью или картинкой меня не устраивал, хотелось чего-то анимированного. И я придумал сохранять ходы пользователя, а потом воспроизводить их после окончания уровня. Для этого я создал BoardDrawable:
Остаётся записать все ходы пользователя, а потом при прохождении уровня показать анимацию. Для переключения вида игра/конечный экран, я использовал ViewSwitcher, который позволяет не только сменить разметку, но и сделать это с анимацией.
2016-12-24.apk
День 10
Для оповещения о проигрыше тоже решил сделать анимацию. Только теперь со смайликом. Причём цвет фона и смайлика каждый раз будет разным, а на каждом шаге анимации будет закрашиваться только фон.
День 11
Раз уж ходы пользователя сохраняются, почему бы не показать их ещё где-нибудь? Например, при рестарте, давая возможность пройти уровень так же, как и в прошлый раз, но где-нибудь выбрать выбрать иной цвет. Выводом истории я и занялся:
Остальное время занимался исправлением ошибок. Например, не сохранялся экран (игра/конечная заставка), кнопка перехода на новый уровень не учитывала, что после 100-го уровня больше ничего нет, и т.д.
2016-12-26.apk
День 12
Уже 12-ый день, а экрана статистики и настроек у меня всё нет, поэтому начал с них.
На экране статистики нужно вывести число игр, побед, проигрышей, нажатий и залитых клеток. А также для каждого цвета (вернее индекса в палитре) показать количество нажатий. Первое выводится в обычном TextView, а вот для второго снова потребовался новый View. Рассчитываем количество нажатий в процентах и выводим диаграмму:
В редакторе разметки Android Studio для всех кастомных вьюшек хотелось бы видеть не просто цветные прямоугольники, а что-то более вменяемое, поэтому потратил немного времени на реализацию превью:
Добавил настройки. Обычные настройки, с обычными ListPreference, в которых можно выбрать тему (дневная/ночная) и палитру (стандартная/материал). Эти настройки мне не понравились, нужно было придумать что-то более симпатичное, но пока что голова была занята другим — хотелось создать ещё несколько палитр.
Просто подобрать 8 отличающихся цветов вроде было несложно, но в совокупности они зачастую смотрелись плохо. Когда, казалось, нашел идеальные сочетания цветов, собрал и запустил на устройстве, выяснялось, что они не совсем подходят и надо вновь где-то изменять яркость. Поскольку каждая пересборка занимала не менее минуты, продолжать так и далее было невозможно.
Чтобы хоть как-то сделать подбор цветов наглядным и не сойти с ума, я прибегнул к помощи html. Вывел палитру и игровое поле для дневной темы, а рядом для ночной. Затем при помощи dev tools подбирал и менял цвета в палитре. После этого нажимал кнопку и получал список цветов в RGB.
Скрипт здесь https://jsfiddle.net/aNNiMON/tqkynp5z/
Так удалось подобрать ещё 4 палитры. И всё равно, моей ошибкой было то, что я подбирал цвета на ноутбуке, но потом не сверял их на дисплее телефона. На телефоне всё выглядело не совсем так, как задумано. Кое-где разница была в лучшую сторону, а кое-где в худшую.
2016-12-27.apk
День 13
Всё необходимое для режима быстрой игры у меня было, но активировать его пока что было нельзя. Поэтому добавил пункт в главном меню, который запускает игру со случайными настройками количества цветов и размера доски.
Вслед за новым пунктом сменил и логотип. Теперь вместо текстового названия выводился BoardDrawable с названием игры. Приятным дополнением стало то, что на этот логотип можно нажимать.
День 14
В принципе, игра была почти готова. Оставалось что-то придумать с настройками, сделать перевод на русский, поправить стили для других экранов и исправить найденные ошибки. Одним словом рутина.
Кстати, если захотите сделать квадратный View, не пишите так:
Даже если вы принудительно поставите портретный режим, места по высоте может не хватить в контейнере. Поэтому лучше вычислить ширину и высоту, а потом взять минимальное значение:
Как сделать новые настройки я к вечеру таки придумал, но сделал их лишь на
День 15
Теперь экран настроек лучше соответствовал игре.
В течение дня я ещё не раз запускал игру, чтобы найти какие-нибудь ошибки или недочёты, исправлял их и запускал всё снова. Это было 30 декабря 2016 года, 15-ый день разработки игры подряд. Релизить в этот день я, конечно, не стал, потому что были ещё планы встроить рекламу. Но этим я занимался уже в новом году.
Бонус #0. Релиз
На 16-ый день (но уже не подряд) встроил рекламу AdMob.
На 17-ый добавил возможность покупки отключения рекламы.
И только на 18-ый состоялся релиз версии 1.0.
За вычетом рекламы, игру я всё-таки успел сделать до Нового Года.
Бонус #1. Поддержка Android 2.3
Изначально игра нацеливалась на Android 4.0+, но после релиза предложили добавить поддержку 2.3. Покажу на примере анимаций индикатора, как я решил проблему. Класс Animator, который для этого используется, появился в API 11.
Создал интерфейс AnimatorCompat со всеми используемыми методами:
Для API 11+ создал реализацию, делегирующую объект Animator:
А для ранних версий создал пустую реализацию:
Теперь заменяем все объекты Animator на AnimatorCompat:
А при создании объекта проверяем версию API и выбираем нужную реализацию:
Именно так и работают большинство Compat-классов в Android Support Library.
Бонус #2. Что было ещё добавлено и как?
На 21 день состоялся релиз версии 1.1. За два дня сделал редизайн экрана выбора режимов. Теперь они выбираются перелистыванием во FragmentPagerAdapter. Вынес быструю игру в первый пункт меню и добавил возможность настроить количество цветов и размер игрового поля.
А ещё добавил обучение, о реализации которого расскажу, пожалуй, в отдельной статье.
Мне не раз говорили об анимациях, поэтому появилась идея сделать анимацию заливки в виде RippleAnimation. С этим была наибольшая сложность, я никак не мог придумать, как это лучше всего сделать. Хотел сделать слоями: рисовать сначала тот цвет, который мы заливаем, потом постепенно анимировать круг новым цветом, а в конце рисовать всё поле, но уже без первых двух цветов. Так я и сделал бы, если бы вовремя не открыл для себя clipPath.
Всё оказалось достаточно простым. Пройтись по игровому полю и добавить в Path клетки с тем цветом, который мы будем заливать. Затем вызвать clipPath с заданным Path, а потом рисовать круг.
Однако этот способ не работает в аппаратном ускорении на Android API меньше 18, так что нужно ставить программный режим для отображения View.
Дальше захотелось добавить пасхалки и тут не обошлось без пиксель арта. Говорить о них я так же не буду, просто покажу одну из них:
В 25-ый день вышел релиз 1.2, содержащий всё вышеперечисленное.
Затем я приступил к мультиплееру. Ввёл интерфейс MoveStrategy:
И две реализации. PlayerMove для игры друг с другом:
и AndroidMove для игры против андроида:
Алгоритм хода андроида не очень сложный. Он просчитывает варианты только на один ход вперёд. Для каждого возможного цвета (а их всегда равно MAX_COLORS-2) считает количество залитых клеток для себя, а затем для игрока и выбирает лучший цвет в свою пользу.
При таких условиях, есть одна маленькая особенность: если алгоритм посчитает, что на следующем ходу у игрока есть такой цвет, который даст ему преимущество, то андроид начнёт западлить и сам зальёт своё поле этим цветом. Теперь, зная это, попробуйте поиграть против андроида и вы поймёте. Иногда это выглядит забавно)
На 34-ый день вышел релиз 1.3, в котором добавился мультиплеер, описанный выше.
Бонус #3. Как спустя почти два года я сумел воссоздать историю разработки проекта?
Думаю, ответ на этот вопрос тоже будет интересен.
Во-первых, git. По нему очень здорово восстанавливать историю, так как видно весь код, который был написан.
Во-вторых, я начинал писать игру уже с мыслями о том, что когда-нибудь напишу о ходе разработки, поэтому в конце каждого дня записывал в блокнот всё нужное. Это были просто небольшие заметки о проделанной работе, чтобы знать, в какой день чем была забита голова, что хотел сделать и что в итоге сделал. И если кое-что git мог перенести не на тот день, в котором это было сделано, то заметка восстановит справедливость. Например, вечером я сделал переключение тем, закоммитил, а потом утром немного доработал и сделал amend commit. Теперь весь коммит будет уже сегодняшним. Но вчерашняя запись о том, что смену тем я таки сделал, поможет в будущем восстановить порядок действий.
В-третьих, канбан-доски. Где-то на третий или четвёртый день я вспомнил о их существовании и решил применить. Очень помогло, всем советую.
Заключение
Конечно, то, что я написал в этой статье — лишь поверхностное описание. Кроме кода, задач и идей есть ещё и азарт, злость на жрущий Gradle, собирающий проект по две-четыре минуты на моём железе, усталость, двукратное удаление темы на 4pda по этой игре, потому что скачиваний было недостаточно (во второй раз я пытался накрутить, было весело, но тоже не помогло), интерес получить новый опыт, трепетное заполнение данных при публикации игры на Google Play, а также какая-то привязанность, что ли. В общем что-то, что заставляло меня на протяжении полутора лет вспоминать об игре и о том, что я хотел написать о процессе её разработки и в конце концов заставило написать эту статью.
Всем, кто дочитал, спасибо. Исходники (пока что?) не выкладываю, но если нужно конкретнее описать какую-то реализацию из игры, пишите в комментариях, я добавлю.
Ну и, наконец, игра на Google Play.
День 8
День рефакторинга. Пока что код GameActivity занимал не очень много места, но с добавлением различных режимов он мог возрастать в разы. Поэтому я создал абстрактный класс GameType, в котором была общая для всех режимов логика, а всё остальное реализовывалось в классах-наследниках: LevelType — режим прохождения уровня и RandomType — режим случайной игры.
- public abstract class GameType implements ColorButtonListener {
- public void onColorButtonClick(View v, int index) {
- final int filled = board.fill(index);
- if (filled > 0) {
- moves++;
- }
- updateMovesCounter();
- statistics()
- .updateClicksFor(index)
- .updateFilledCount(filled);
- viewsPack.boardView.invalidate();
- if (board.isCompleted()) {
- statistics()
- .updateGamesPlayedCount()
- .updateWinsCount();
- onBoardCompleted();
- } else {
- onMoveEnd();
- }
- }
- protected void onBoardCompleted() {
- Toast.makeText(activity, "Completed", Toast.LENGTH_SHORT).show();
- }
- public void onThemeUpdate() {
- buttonsManager.palette(palette);
- viewsPack.boardView.invalidate();
- }
- protected abstract BoardGenerator generator();
- protected abstract void updateMovesCounter();
- protected abstract void onMoveEnd();
- protected Statistics statistics() {
- return Statistics.with(activity);
- }
- }
- public class RandomType extends GameType {
- private final long seed;
- public RandomType(GameActivity activity, int boardSize, int maxColors, long seed) {
- super(activity, boardSize, maxColors);
- this.seed = seed;
- }
- @Override
- protected BoardGenerator generator() {
- return new BoardGenerator(seed);
- }
- @Override
- protected void updateMovesCounter() {
- viewsPack.movesInfo.setText(String.format(Locale.ENGLISH, "%d moves", moves));
- }
- @Override
- protected void onMoveEnd() { }
- }
- public class LevelType extends GameType {
- private final int level;
- private final int maxMoves;
- private final BoardGenerator generator;
- public LevelType(GameActivity activity, int boardSize, int maxColors, int level) {
- super(activity, boardSize, maxColors);
- this.level = level;
- maxMoves = FileUtils.readLevelMoves(activity, boardSize, maxColors, level);
- generator = new BoardGenerator(level);
- }
- @Override
- protected BoardGenerator generator() {
- return generator;
- }
- @Override
- protected void updateMovesCounter() {
- viewsPack.movesInfo.setText(String.format(Locale.ENGLISH, "%d / %d", moves, maxMoves));
- }
- @Override
- protected void onBoardCompleted() {
- super.onBoardCompleted();
- saveLevelCompletion();
- }
- @Override
- protected void onMoveEnd() {
- if (moves >= maxMoves) {
- statistics()
- .updateGamesPlayedCount()
- .updateLossCount();
- Toast.makeText(activity, "Loose", Toast.LENGTH_SHORT).show();
- }
- }
- }
Очередная сложность, которую предстояло решить — сохранение состояний. Если во время игры сменить тему, уровень начнётся заново. Поэтому нужно сохранить всё, что можно, начиная от параметров игры (seed, размер доски, количество цветов), заканчивая цветами клеток в доске.
- public class GameActivity extends ThemeableActivity implements View.OnClickListener {
- protected void onCreate(Bundle savedInstanceState) {
- // ..
- if (savedInstanceState != null) {
- boardSize = savedInstanceState.getInt(EXTRA_BOARD_SIZE, 8);
- maxColors = savedInstanceState.getInt(EXTRA_MAX_COLORS, 8);
- seed = savedInstanceState.getLong(EXTRA_SEED, RANDOM_SEED);
- }
- // ..
- }
- @Override
- protected void onSaveInstanceState(Bundle outState) {
- super.onSaveInstanceState(outState);
- outState.putInt(EXTRA_BOARD_SIZE, gameType.getBoardSize());
- outState.putInt(EXTRA_MAX_COLORS, gameType.getMaxColors());
- outState.putLong(EXTRA_SEED, seed);
- gameType.onSaveState(outState);
- }
- }
- public abstract class GameType implements ColorButtonListener {
- public void onRestoreState(Bundle state) {
- moves = state.getInt("moves", 0);
- final int[][] boardCells = new int[boardSize][boardSize];
- for (int y = 0; y < boardSize; y++) {
- boardCells[y] = state.getIntArray("board_" + y);
- }
- board.setBoard(boardCells);
- updateMovesCounter();
- viewsPack.boardView.invalidate();
- }
- public void onSaveState(Bundle state) {
- state.putInt("moves", moves);
- final int[][] boardCells = board.getBoard();
- for (int y = 0; y < boardSize; y++) {
- state.putIntArray("board_" + y, boardCells[y]);
- }
- }
- }
А ещё нужно было уже прикручивать статистику. Пока что сделал вывод текстом в Toast:
- final StringBuilder sb = new StringBuilder();
- Statistics stat = Statistics.with(this);
- sb.append("Clicks: ").append(Arrays.toString(stat.getClicks())).append("\n");
- sb.append("Filled: ").append(stat.getFilledCount()).append(" blocks\n");
- sb.append("Games: ").append(stat.getGamesPlayedCount()).append("\n");
- sb.append("Wins: ").append(stat.getWinsCount()).append("\n");
- sb.append("Looses: ").append(stat.getLossCount()).append("\n");
- final long s = stat.getPlayTime() / 1000;
- sb.append("Play time: ")
- .append(String.format(Locale.ENGLISH, "%d:%02d:%02d",
- s / 3600, (s % 3600) / 60, (s % 60)));
- Toast.makeText(this, sb, Toast.LENGTH_LONG).show();
Ещё почитал про векторную анимацию иконок, хотел прикрутить при смене темы, но не вышло. Оставил на потом.
День 9
О векторной анимации я читал вчера не зря. Пора бы уже добавить иконки и сделать игровое меню со сменой темы и рестартом. В то время в Android Studio как раз добавили повсеместную поддержку VectorDrawables и можно было не рисовать кучу иконок разных размеров. Это ускорило разработку.
Дальше у меня появилась идея для конечной заставки, оповещающей об успешном прохождении уровня или о проигрыше. Просто статичный экран с надписью или картинкой меня не устраивал, хотелось чего-то анимированного. И я придумал сохранять ходы пользователя, а потом воспроизводить их после окончания уровня. Для этого я создал BoardDrawable:
- public class BoardDrawable extends Drawable implements Animatable {
- private static final long ANIMATION_DELAY = 1000;
- private final Board board;
- private final int[][] initialBoard;
- private int movesCount;
- private final List<Integer> moves;
- private Palette palette;
- private int index;
- public BoardDrawable(Board board, List<Integer> moves) {
- this.board = board;
- this.movesCount = moves.size();
- this.moves = new ArrayList<>(moves);
- index = 0;
- }
- @Override
- public void start() {
- handler.sendEmptyMessage(0);
- }
- @Override
- public void stop() {
- handler.removeMessages(0);
- }
- @Override
- public void draw(Canvas canvas) {
- if (board == null) return;
- if (palette == null) return;
- int size = board.getBoardSize();
- int[] colors = palette.getColors();
- int cellSize = Math.min(canvas.getWidth(), canvas.getHeight()) / size;
- for (int y = 0; y < size; y++) {
- for (int x = 0; x < size; x++) {
- paint.setColor(colors[board.getCellAt(x, y)]);
- final int left = x * cellSize;
- final int top = y * cellSize;
- canvas.drawRect(left, top, left + cellSize, top + cellSize, paint);
- }
- }
- }
- private final Handler handler = new Handler() {
- @Override
- public void handleMessage(Message msg) {
- if (index >= movesCount) {
- board.setBoard(initialBoard);
- index = 0;
- } else {
- board.fill(moves.get(index++));
- }
- invalidateSelf();
- handler.sendEmptyMessageDelayed(0, ANIMATION_DELAY);
- }
- };
- }
Остаётся записать все ходы пользователя, а потом при прохождении уровня показать анимацию. Для переключения вида игра/конечный экран, я использовал ViewSwitcher, который позволяет не только сменить разметку, но и сделать это с анимацией.
2016-12-24.apk
День 10
Для оповещения о проигрыше тоже решил сделать анимацию. Только теперь со смайликом. Причём цвет фона и смайлика каждый раз будет разным, а на каждом шаге анимации будет закрашиваться только фон.
- public class BoardImage {
- public static BoardImage sad(int maxColors) {
- final int[][] cells = {
- {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
- {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
- {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
- {0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 0, 0},
- {0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 0, 0},
- {0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 0, 0},
- {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
- {0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0},
- {0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0},
- {0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0},
- {0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0},
- {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
- {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
- };
- return create(cells, 1, maxColors);
- }
- private static BoardImage create(int[][] cells, int usedColors, int maxColors) {
- final List<Integer> moves = new ArrayList<>(maxColors - usedColors + 1);
- for (int i = usedColors + 1; i < maxColors; i++) {
- moves.add(i);
- }
- moves.add(0);
- final Random rnd = new Random();
- Collections.shuffle(moves, rnd);
- if (rnd.nextInt(maxColors) >= usedColors) {
- final int size = cells.length;
- for (int i = 1; i <= usedColors; i++) {
- int replaceWith = moves.remove(0);
- for (int y = 0; y < size; y++) {
- for (int x = 0; x < size; x++) {
- if (cells[y][x] == i) {
- cells[y][x] = replaceWith;
- }
- }
- }
- moves.add(i);
- }
- }
- return new BoardImage(Board.from(cells).maxColors(maxColors), moves);
- }
- }
День 11
Раз уж ходы пользователя сохраняются, почему бы не показать их ещё где-нибудь? Например, при рестарте, давая возможность пройти уровень так же, как и в прошлый раз, но где-нибудь выбрать выбрать иной цвет. Выводом истории я и занялся:
- public class MovesView extends View {
- private final Paint paint;
- private List<Integer> moves;
- private Palette palette;
- private int index, movesSize;
- private int windowSize;
- public MovesView moves(List<Integer> moves) {
- this.moves = moves;
- this.movesSize = moves.size();
- this.windowSize = Math.min(movesSize, 10);
- return this;
- }
- public void updateIndex() {
- index++;
- invalidate();
- }
- @Override
- protected void onDraw(Canvas canvas) {
- if (isInEditMode()) {
- canvas.drawColor(Color.LTGRAY);
- return;
- }
- if (movesSize == 0) return;
- if (palette == null) return;
- if (windowSize == 0) return;
- if (index >= movesSize) return;
- int[] colors = palette.getColors();
- int width = canvas.getWidth();
- int cellSize = canvas.getHeight();
- int spaceWidth = width / (windowSize + 1);
- List<Integer> subList = moves.subList(index, Math.min(index + windowSize, movesSize));
- int i = 0;
- for (int move : subList) {
- i++;
- paint.setColor(colors[move]);
- final int left = spaceWidth * i - cellSize / 2;
- canvas.drawRect(left, 0, left + cellSize, cellSize, paint);
- }
- }
- }
Остальное время занимался исправлением ошибок. Например, не сохранялся экран (игра/конечная заставка), кнопка перехода на новый уровень не учитывала, что после 100-го уровня больше ничего нет, и т.д.
2016-12-26.apk
День 12
Уже 12-ый день, а экрана статистики и настроек у меня всё нет, поэтому начал с них.
На экране статистики нужно вывести число игр, побед, проигрышей, нажатий и залитых клеток. А также для каждого цвета (вернее индекса в палитре) показать количество нажатий. Первое выводится в обычном TextView, а вот для второго снова потребовался новый View. Рассчитываем количество нажатий в процентах и выводим диаграмму:
В редакторе разметки Android Studio для всех кастомных вьюшек хотелось бы видеть не просто цветные прямоугольники, а что-то более вменяемое, поэтому потратил немного времени на реализацию превью:
- // MovesView.init()
- if (isInEditMode()) {
- palette(Palette.defaultPalette());
- moves(Arrays.asList(2, 1, 4, 0, 1, 5, 3, 2, 0, 4));
- }
- // BoardView.init()
- if (isInEditMode()) {
- Palette palette = Palette.defaultPalette();
- board(Board.create(16).maxColors(6).generate(new BoardGenerator(10L)));
- palette(palette);
- }
- // ButtonsStatView.init()
- if (isInEditMode()) {
- palette(Palette.defaultPalette());
- clickCounts(new int[] {33, 50, 75, 12, 18, 21, 0, 13});
- }
Добавил настройки. Обычные настройки, с обычными ListPreference, в которых можно выбрать тему (дневная/ночная) и палитру (стандартная/материал). Эти настройки мне не понравились, нужно было придумать что-то более симпатичное, но пока что голова была занята другим — хотелось создать ещё несколько палитр.
Просто подобрать 8 отличающихся цветов вроде было несложно, но в совокупности они зачастую смотрелись плохо. Когда, казалось, нашел идеальные сочетания цветов, собрал и запустил на устройстве, выяснялось, что они не совсем подходят и надо вновь где-то изменять яркость. Поскольку каждая пересборка занимала не менее минуты, продолжать так и далее было невозможно.
Чтобы хоть как-то сделать подбор цветов наглядным и не сойти с ума, я прибегнул к помощи html. Вывел палитру и игровое поле для дневной темы, а рядом для ночной. Затем при помощи dev tools подбирал и менял цвета в палитре. После этого нажимал кнопку и получал список цветов в RGB.
Скрипт здесь https://jsfiddle.net/aNNiMON/tqkynp5z/
Так удалось подобрать ещё 4 палитры. И всё равно, моей ошибкой было то, что я подбирал цвета на ноутбуке, но потом не сверял их на дисплее телефона. На телефоне всё выглядело не совсем так, как задумано. Кое-где разница была в лучшую сторону, а кое-где в худшую.
2016-12-27.apk
День 13
Всё необходимое для режима быстрой игры у меня было, но активировать его пока что было нельзя. Поэтому добавил пункт в главном меню, который запускает игру со случайными настройками количества цветов и размера доски.
Вслед за новым пунктом сменил и логотип. Теперь вместо текстового названия выводился BoardDrawable с названием игры. Приятным дополнением стало то, что на этот логотип можно нажимать.
День 14
В принципе, игра была почти готова. Оставалось что-то придумать с настройками, сделать перевод на русский, поправить стили для других экранов и исправить найденные ошибки. Одним словом рутина.
Кстати, если захотите сделать квадратный View, не пишите так:
- @Override
- public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
- super.onMeasure(widthMeasureSpec, widthMeasureSpec);
- }
Даже если вы принудительно поставите портретный режим, места по высоте может не хватить в контейнере. Поэтому лучше вычислить ширину и высоту, а потом взять минимальное значение:
- @Override
- public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
- super.onMeasure(widthMeasureSpec, heightMeasureSpec);
- final int width = MeasureSpec.getSize(widthMeasureSpec);
- final int height = MeasureSpec.getSize(heightMeasureSpec);
- final int modeWidth = MeasureSpec.getMode(widthMeasureSpec);
- final int modeHeight = MeasureSpec.getMode(heightMeasureSpec);
- final int size;
- if (modeWidth == MeasureSpec.UNSPECIFIED) {
- size = height;
- } else if (modeHeight == MeasureSpec.UNSPECIFIED) {
- size = width;
- } else {
- size = Math.min(width, height);
- }
- setMeasuredDimension(size, size);
- }
Как сделать новые настройки я к вечеру таки придумал, но сделал их лишь на
День 15
Теперь экран настроек лучше соответствовал игре.
В течение дня я ещё не раз запускал игру, чтобы найти какие-нибудь ошибки или недочёты, исправлял их и запускал всё снова. Это было 30 декабря 2016 года, 15-ый день разработки игры подряд. Релизить в этот день я, конечно, не стал, потому что были ещё планы встроить рекламу. Но этим я занимался уже в новом году.
Бонус #0. Релиз
На 16-ый день (но уже не подряд) встроил рекламу AdMob.
На 17-ый добавил возможность покупки отключения рекламы.
И только на 18-ый состоялся релиз версии 1.0.
За вычетом рекламы, игру я всё-таки успел сделать до Нового Года.
Бонус #1. Поддержка Android 2.3
Изначально игра нацеливалась на Android 4.0+, но после релиза предложили добавить поддержку 2.3. Покажу на примере анимаций индикатора, как я решил проблему. Класс Animator, который для этого используется, появился в API 11.
Создал интерфейс AnimatorCompat со всеми используемыми методами:
- public interface AnimatorCompat {
- void setDuration(int duration);
- boolean isRunning();
- void end();
- void cancel();
- void setTarget(Object target);
- void start();
- }
Для API 11+ создал реализацию, делегирующую объект Animator:
- @TargetApi(Build.VERSION_CODES.HONEYCOMB)
- public final class AnimatorWrapper implements AnimatorCompat {
- private final Animator animator;
- public AnimatorWrapper(Animator animator) {
- this.animator = animator;
- }
- @Override
- public void setDuration(int duration) {
- animator.setDuration(duration);
- }
- @Override
- public boolean isRunning() {
- return animator.isRunning();
- }
- @Override
- public void end() {
- animator.end();
- }
- @Override
- public void cancel() {
- animator.cancel();
- }
- @Override
- public void setTarget(Object target) {
- animator.setTarget(target);
- }
- @Override
- public void start() {
- animator.start();
- }
- }
А для ранних версий создал пустую реализацию:
- package com.annimon.megafloodit.compat;
- public final class EmptyAnimator implements AnimatorCompat {
- @Override
- public void setDuration(int duration) { }
- @Override
- public boolean isRunning() {
- return false;
- }
- @Override
- public void end() { }
- @Override
- public void cancel() { }
- @Override
- public void setTarget(Object target) { }
- @Override
- public void start() { }
- }
Теперь заменяем все объекты Animator на AnimatorCompat:
- private Animator mAnimatorOut;
- private Animator mAnimatorIn;
- private Animator mImmediateAnimatorOut;
- private Animator mImmediateAnimatorIn;
- // --
- private AnimatorCompat mAnimatorOut;
- private AnimatorCompat mAnimatorIn;
- private AnimatorCompat mImmediateAnimatorOut;
- private AnimatorCompat mImmediateAnimatorIn;
А при создании объекта проверяем версию API и выбираем нужную реализацию:
- private AnimatorCompat createAnimatorOut(Context context) {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
- return new AnimatorWrapper(AnimatorInflater.loadAnimator(context, mAnimatorResId));
- } else {
- return new EmptyAnimator();
- }
- }
Именно так и работают большинство Compat-классов в Android Support Library.
Бонус #2. Что было ещё добавлено и как?
На 21 день состоялся релиз версии 1.1. За два дня сделал редизайн экрана выбора режимов. Теперь они выбираются перелистыванием во FragmentPagerAdapter. Вынес быструю игру в первый пункт меню и добавил возможность настроить количество цветов и размер игрового поля.
А ещё добавил обучение, о реализации которого расскажу, пожалуй, в отдельной статье.
Мне не раз говорили об анимациях, поэтому появилась идея сделать анимацию заливки в виде RippleAnimation. С этим была наибольшая сложность, я никак не мог придумать, как это лучше всего сделать. Хотел сделать слоями: рисовать сначала тот цвет, который мы заливаем, потом постепенно анимировать круг новым цветом, а в конце рисовать всё поле, но уже без первых двух цветов. Так я и сделал бы, если бы вовремя не открыл для себя clipPath.
Всё оказалось достаточно простым. Пройтись по игровому полю и добавить в Path клетки с тем цветом, который мы будем заливать. Затем вызвать clipPath с заданным Path, а потом рисовать круг.
- public void draw(Canvas canvas, Paint paint,
- int boardSize, int cellSize,
- int xStart, int yStart) {
- final int size = currentArea.length;
- animationFillMask.reset();
- for (int y = 0; y < size; y++) {
- for (int x = 0; x < size; x++) {
- if (!currentArea[y][x]) continue;
- int left = x * cellSize + xStart;
- int top = y * cellSize + yStart;
- animationFillMask.addRect(left, top, left + cellSize, top + cellSize, Path.Direction.CW);
- }
- }
- animationFillMask.close();
- int saved = canvas.save();
- canvas.clipPath(animationFillMask);
- canvas.drawCircle(0, 0, (float) (frame * (boardSize * 1.5) / maxFrames), paint);
- canvas.restoreToCount(saved);
- }
Однако этот способ не работает в аппаратном ускорении на Android API меньше 18, так что нужно ставить программный режим для отображения View.
Дальше захотелось добавить пасхалки и тут не обошлось без пиксель арта. Говорить о них я так же не буду, просто покажу одну из них:
В 25-ый день вышел релиз 1.2, содержащий всё вышеперечисленное.
Затем я приступил к мультиплееру. Ввёл интерфейс MoveStrategy:
- public interface MoveStrategy {
- void onSuccessMove();
- void onPreColorButtonClick();
- void onMoveEnd();
- void onFillComplete();
- void onBoardCompleted();
- }
И две реализации. PlayerMove для игры друг с другом:
- public class PlayerMove implements MoveStrategy {
- @Override
- public void onPreColorButtonClick() {
- type.getViewsPack().boardView.clearCurrentArea();
- }
- @Override
- public void onFillComplete() { }
- @Override
- public void onSuccessMove() {
- type.switchFirstPlayerMove();
- }
- @Override
- public void onMoveEnd() {
- type.getViewsPack().boardView.rotate(RotatableBoard.Rotation.R180, null);
- type.updateMovesCounter();
- }
- @Override
- public void onBoardCompleted() {
- type.statistics()
- .updateGamesPlayedCount();
- }
- }
и AndroidMove для игры против андроида:
- public class AndroidMove implements MoveStrategy {
- @Override
- public void onPreColorButtonClick() { }
- @Override
- public void onFillComplete() {
- final int last = type.getBoard().getBoardSize() - 1;
- final List<Integer> possibleMoves = new ArrayList<>(type.getMaxColors());
- for (int i = 0; i < type.getMaxColors(); i++) {
- possibleMoves.add(i);
- }
- // Android can't use current color and colot of his opponent
- possibleMoves.remove((Integer) type.getBoard().getCellAt(0, 0));
- possibleMoves.remove((Integer) type.getBoard().getCellAt(last, last));
- final List<Pair<Integer, Integer>> ranks = new ArrayList<>(possibleMoves.size());
- for (Integer possibleMove : possibleMoves) {
- int[][] board = type.getBoard().getBoardCopy();
- Board.fill(possibleMove, last, last, board, null);
- final int count = Board.fill(-1, last, last, board, null);
- board = type.getBoard().getBoardCopy();
- Board.fill(possibleMove, 0, 0, board, null);
- final int countOpposite = Board.fill(-1, 0, 0, board, null);
- ranks.add(Pair.create(possibleMove, count + countOpposite));
- }
- Collections.sort(ranks, new Comparator<Pair<Integer, Integer>>() {
- @Override
- public int compare(Pair<Integer, Integer> o1, Pair<Integer, Integer> o2) {
- return o2.second.compareTo(o1.second);
- }
- });
- final int androidMove = ranks.get(0).first;
- type.getViewsPack().boardView.clearCurrentArea();
- type.getViewsPack().boardView.fillByAndroid(androidMove);
- type.updateMovesCounter();
- if (type.getBoard().isCompleted()) {
- // Android won
- type.statistics()
- .updateGamesPlayedCount()
- .updateLossCount();
- type.onBoardCompleted();
- }
- }
- @Override
- public void onSuccessMove() { }
- @Override
- public void onMoveEnd() { }
- @Override
- public void onBoardCompleted() {
- // Player won
- type.statistics()
- .updateGamesPlayedCount()
- .updateWinsCount();
- }
- }
Алгоритм хода андроида не очень сложный. Он просчитывает варианты только на один ход вперёд. Для каждого возможного цвета (а их всегда равно MAX_COLORS-2) считает количество залитых клеток для себя, а затем для игрока и выбирает лучший цвет в свою пользу.
При таких условиях, есть одна маленькая особенность: если алгоритм посчитает, что на следующем ходу у игрока есть такой цвет, который даст ему преимущество, то андроид начнёт западлить и сам зальёт своё поле этим цветом. Теперь, зная это, попробуйте поиграть против андроида и вы поймёте. Иногда это выглядит забавно)
На 34-ый день вышел релиз 1.3, в котором добавился мультиплеер, описанный выше.
Бонус #3. Как спустя почти два года я сумел воссоздать историю разработки проекта?
Думаю, ответ на этот вопрос тоже будет интересен.
Во-первых, git. По нему очень здорово восстанавливать историю, так как видно весь код, который был написан.
Во-вторых, я начинал писать игру уже с мыслями о том, что когда-нибудь напишу о ходе разработки, поэтому в конце каждого дня записывал в блокнот всё нужное. Это были просто небольшие заметки о проделанной работе, чтобы знать, в какой день чем была забита голова, что хотел сделать и что в итоге сделал. И если кое-что git мог перенести не на тот день, в котором это было сделано, то заметка восстановит справедливость. Например, вечером я сделал переключение тем, закоммитил, а потом утром немного доработал и сделал amend commit. Теперь весь коммит будет уже сегодняшним. Но вчерашняя запись о том, что смену тем я таки сделал, поможет в будущем восстановить порядок действий.
В-третьих, канбан-доски. Где-то на третий или четвёртый день я вспомнил о их существовании и решил применить. Очень помогло, всем советую.
История коммитов проекта
Заключение
Конечно, то, что я написал в этой статье — лишь поверхностное описание. Кроме кода, задач и идей есть ещё и азарт, злость на жрущий Gradle, собирающий проект по две-четыре минуты на моём железе, усталость, двукратное удаление темы на 4pda по этой игре, потому что скачиваний было недостаточно (во второй раз я пытался накрутить, было весело, но тоже не помогло), интерес получить новый опыт, трепетное заполнение данных при публикации игры на Google Play, а также какая-то привязанность, что ли. В общем что-то, что заставляло меня на протяжении полутора лет вспоминать об игре и о том, что я хотел написать о процессе её разработки и в конце концов заставило написать эту статью.
Всем, кто дочитал, спасибо. Исходники (пока что?) не выкладываю, но если нужно конкретнее описать какую-то реализацию из игры, пишите в комментариях, я добавлю.
Ну и, наконец, игра на Google Play.