Как написать игру под Android за 15 дней. История создания Mega Flood-It. Часть вторая

от
GameDev    android, flood-it, рекурсия, история разработки, канбан, пасхалки

Продолжение статьи о разработке игры. Начало здесь.
mfi_logo2.png

День 8
День рефакторинга. Пока что код GameActivity занимал не очень много места, но с добавлением различных режимов он мог возрастать в разы. Поэтому я создал абстрактный класс GameType, в котором была общая для всех режимов логика, а всё остальное реализовывалось в классах-наследниках: LevelType — режим прохождения уровня и RandomType — режим случайной игры.
  1. public abstract class GameType implements ColorButtonListener {
  2.     public void onColorButtonClick(View v, int index) {
  3.         final int filled = board.fill(index);
  4.         if (filled > 0) {
  5.             moves++;
  6.         }
  7.         updateMovesCounter();
  8.         statistics()
  9.                 .updateClicksFor(index)
  10.                 .updateFilledCount(filled);
  11.         viewsPack.boardView.invalidate();
  12.  
  13.         if (board.isCompleted()) {
  14.             statistics()
  15.                     .updateGamesPlayedCount()
  16.                     .updateWinsCount();
  17.             onBoardCompleted();
  18.         } else {
  19.             onMoveEnd();
  20.         }
  21.     }
  22.  
  23.     protected void onBoardCompleted() {
  24.         Toast.makeText(activity, "Completed", Toast.LENGTH_SHORT).show();
  25.     }
  26.  
  27.     public void onThemeUpdate() {
  28.         buttonsManager.palette(palette);
  29.         viewsPack.boardView.invalidate();
  30.     }
  31.  
  32.     protected abstract BoardGenerator generator();
  33.  
  34.     protected abstract void updateMovesCounter();
  35.  
  36.     protected abstract void onMoveEnd();
  37.  
  38.     protected Statistics statistics() {
  39.         return Statistics.with(activity);
  40.     }
  41. }
  42.  
  43. public class RandomType extends GameType {
  44.     private final long seed;
  45.  
  46.     public RandomType(GameActivity activity, int boardSize, int maxColors, long seed) {
  47.         super(activity, boardSize, maxColors);
  48.         this.seed = seed;
  49.     }
  50.  
  51.     @Override
  52.     protected BoardGenerator generator() {
  53.         return new BoardGenerator(seed);
  54.     }
  55.  
  56.     @Override
  57.     protected void updateMovesCounter() {
  58.         viewsPack.movesInfo.setText(String.format(Locale.ENGLISH, "%d moves", moves));
  59.     }
  60.  
  61.     @Override
  62.     protected void onMoveEnd() { }
  63. }
  64.  
  65. public class LevelType extends GameType {
  66.     private final int level;
  67.     private final int maxMoves;
  68.     private final BoardGenerator generator;
  69.  
  70.     public LevelType(GameActivity activity, int boardSize, int maxColors, int level) {
  71.         super(activity, boardSize, maxColors);
  72.         this.level = level;
  73.         maxMoves = FileUtils.readLevelMoves(activity, boardSize, maxColors, level);
  74.         generator = new BoardGenerator(level);
  75.     }
  76.  
  77.     @Override
  78.     protected BoardGenerator generator() {
  79.         return generator;
  80.     }
  81.  
  82.     @Override
  83.     protected void updateMovesCounter() {
  84.         viewsPack.movesInfo.setText(String.format(Locale.ENGLISH, "%d / %d", moves, maxMoves));
  85.     }
  86.  
  87.     @Override
  88.     protected void onBoardCompleted() {
  89.         super.onBoardCompleted();
  90.         saveLevelCompletion();
  91.     }
  92.  
  93.     @Override
  94.     protected void onMoveEnd() {
  95.         if (moves >= maxMoves) {
  96.             statistics()
  97.                     .updateGamesPlayedCount()
  98.                     .updateLossCount();
  99.             Toast.makeText(activity, "Loose", Toast.LENGTH_SHORT).show();
  100.         }
  101.     }
  102. }

Очередная сложность, которую предстояло решить — сохранение состояний. Если во время игры сменить тему, уровень начнётся заново. Поэтому нужно сохранить всё, что можно, начиная от параметров игры (seed, размер доски, количество цветов), заканчивая цветами клеток в доске.
  1. public class GameActivity extends ThemeableActivity implements View.OnClickListener {
  2.     protected void onCreate(Bundle savedInstanceState) {
  3.         // ..
  4.         if (savedInstanceState != null) {
  5.             boardSize = savedInstanceState.getInt(EXTRA_BOARD_SIZE, 8);
  6.             maxColors = savedInstanceState.getInt(EXTRA_MAX_COLORS, 8);
  7.             seed = savedInstanceState.getLong(EXTRA_SEED, RANDOM_SEED);
  8.         }
  9.         // ..
  10.     }
  11.  
  12.     @Override
  13.     protected void onSaveInstanceState(Bundle outState) {
  14.         super.onSaveInstanceState(outState);
  15.         outState.putInt(EXTRA_BOARD_SIZE, gameType.getBoardSize());
  16.         outState.putInt(EXTRA_MAX_COLORS, gameType.getMaxColors());
  17.         outState.putLong(EXTRA_SEED, seed);
  18.         gameType.onSaveState(outState);
  19.     }
  20. }
  21.  
  22. public abstract class GameType implements ColorButtonListener {
  23.     public void onRestoreState(Bundle state) {
  24.         moves = state.getInt("moves", 0);
  25.         final int[][] boardCells = new int[boardSize][boardSize];
  26.         for (int y = 0; y < boardSize; y++) {
  27.             boardCells[y] = state.getIntArray("board_" + y);
  28.         }
  29.         board.setBoard(boardCells);
  30.  
  31.         updateMovesCounter();
  32.         viewsPack.boardView.invalidate();
  33.     }
  34.  
  35.     public void onSaveState(Bundle state) {
  36.         state.putInt("moves", moves);
  37.         final int[][] boardCells = board.getBoard();
  38.         for (int y = 0; y < boardSize; y++) {
  39.             state.putIntArray("board_" + y, boardCells[y]);
  40.         }
  41.     }
  42. }

А ещё нужно было уже прикручивать статистику. Пока что сделал вывод текстом в Toast:
  1. final StringBuilder sb = new StringBuilder();
  2. Statistics stat = Statistics.with(this);
  3. sb.append("Clicks: ").append(Arrays.toString(stat.getClicks())).append("\n");
  4. sb.append("Filled: ").append(stat.getFilledCount()).append(" blocks\n");
  5. sb.append("Games: ").append(stat.getGamesPlayedCount()).append("\n");
  6. sb.append("Wins: ").append(stat.getWinsCount()).append("\n");
  7. sb.append("Looses: ").append(stat.getLossCount()).append("\n");
  8. final long s = stat.getPlayTime() / 1000;
  9. sb.append("Play time: ")
  10.         .append(String.format(Locale.ENGLISH, "%d:%02d:%02d",
  11.                 s / 3600, (s % 3600) / 60, (s % 60)));
  12. Toast.makeText(this, sb, Toast.LENGTH_LONG).show();

Ещё почитал про векторную анимацию иконок, хотел прикрутить при смене темы, но не вышло. Оставил на потом.

device-2018-10-07-131849.png


День 9
О векторной анимации я читал вчера не зря. Пора бы уже добавить иконки и сделать игровое меню со сменой темы и рестартом. В то время в Android Studio как раз добавили повсеместную поддержку VectorDrawables и можно было не рисовать кучу иконок разных размеров. Это ускорило разработку.

Дальше у меня появилась идея для конечной заставки, оповещающей об успешном прохождении уровня или о проигрыше. Просто статичный экран с надписью или картинкой меня не устраивал, хотелось чего-то анимированного. И я придумал сохранять ходы пользователя, а потом воспроизводить их после окончания уровня. Для этого я создал BoardDrawable:
  1. public class BoardDrawable extends Drawable implements Animatable {
  2.  
  3.     private static final long ANIMATION_DELAY = 1000;
  4.  
  5.     private final Board board;
  6.     private final int[][] initialBoard;
  7.     private int movesCount;
  8.     private final List<Integer> moves;
  9.     private Palette palette;
  10.  
  11.     private int index;
  12.  
  13.     public BoardDrawable(Board board, List<Integer> moves) {
  14.         this.board = board;
  15.         this.movesCount = moves.size();
  16.         this.moves = new ArrayList<>(moves);
  17.         index = 0;
  18.     }
  19.  
  20.     @Override
  21.     public void start() {
  22.         handler.sendEmptyMessage(0);
  23.     }
  24.  
  25.     @Override
  26.     public void stop() {
  27.         handler.removeMessages(0);
  28.     }
  29.  
  30.     @Override
  31.     public void draw(Canvas canvas) {
  32.         if (board == null) return;
  33.         if (palette == null) return;
  34.  
  35.         int size = board.getBoardSize();
  36.         int[] colors = palette.getColors();
  37.         int cellSize = Math.min(canvas.getWidth(), canvas.getHeight()) / size;
  38.         for (int y = 0; y < size; y++) {
  39.             for (int x = 0; x < size; x++) {
  40.                 paint.setColor(colors[board.getCellAt(x, y)]);
  41.                 final int left = x * cellSize;
  42.                 final int top = y * cellSize;
  43.                 canvas.drawRect(left, top, left + cellSize, top + cellSize, paint);
  44.             }
  45.         }
  46.     }
  47.  
  48.     private final Handler handler = new Handler() {
  49.         @Override
  50.         public void handleMessage(Message msg) {
  51.             if (index >= movesCount) {
  52.                 board.setBoard(initialBoard);
  53.                 index = 0;
  54.             } else {
  55.                 board.fill(moves.get(index++));
  56.             }
  57.             invalidateSelf();
  58.             handler.sendEmptyMessageDelayed(0, ANIMATION_DELAY);
  59.         }
  60.     };
  61. }

Остаётся записать все ходы пользователя, а потом при прохождении уровня показать анимацию. Для переключения вида игра/конечный экран, я использовал ViewSwitcher, который позволяет не только сменить разметку, но и сделать это с анимацией.

device-2018-10-07-135239.gif

2016-12-24.apk


День 10
Для оповещения о проигрыше тоже решил сделать анимацию. Только теперь со смайликом. Причём цвет фона и смайлика каждый раз будет разным, а на каждом шаге анимации будет закрашиваться только фон.
  1. public class BoardImage {
  2.  
  3.     public static BoardImage sad(int maxColors) {
  4.         final int[][] cells = {
  5.                 {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
  6.                 {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
  7.                 {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
  8.                 {0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 0, 0},
  9.                 {0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 0, 0},
  10.                 {0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 0, 0},
  11.                 {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
  12.                 {0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0},
  13.                 {0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0},
  14.                 {0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0},
  15.                 {0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0},
  16.                 {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
  17.                 {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
  18.         };
  19.         return create(cells, 1, maxColors);
  20.     }
  21.  
  22.     private static BoardImage create(int[][] cells, int usedColors, int maxColors) {
  23.         final List<Integer> moves = new ArrayList<>(maxColors - usedColors + 1);
  24.         for (int i = usedColors + 1; i < maxColors; i++) {
  25.             moves.add(i);
  26.         }
  27.         moves.add(0);
  28.         final Random rnd = new Random();
  29.         Collections.shuffle(moves, rnd);
  30.         if (rnd.nextInt(maxColors) >= usedColors) {
  31.             final int size = cells.length;
  32.             for (int i = 1; i <= usedColors; i++) {
  33.                 int replaceWith = moves.remove(0);
  34.                 for (int y = 0; y < size; y++) {
  35.                     for (int x = 0; x < size; x++) {
  36.                         if (cells[y][x] == i) {
  37.                             cells[y][x] = replaceWith;
  38.                         }
  39.                     }
  40.                 }
  41.                 moves.add(i);
  42.             }
  43.         }
  44.         return new BoardImage(Board.from(cells).maxColors(maxColors), moves);
  45.     }
  46. }

device-2018-10-07-140922.gif


День 11
Раз уж ходы пользователя сохраняются, почему бы не показать их ещё где-нибудь? Например, при рестарте, давая возможность пройти уровень так же, как и в прошлый раз, но где-нибудь выбрать выбрать иной цвет. Выводом истории я и занялся:
  1. public class MovesView extends View {
  2.  
  3.     private final Paint paint;
  4.     private List<Integer> moves;
  5.     private Palette palette;
  6.     private int index, movesSize;
  7.     private int windowSize;
  8.  
  9.     public MovesView moves(List<Integer> moves) {
  10.         this.moves = moves;
  11.         this.movesSize = moves.size();
  12.         this.windowSize = Math.min(movesSize, 10);
  13.         return this;
  14.     }
  15.  
  16.     public void updateIndex() {
  17.         index++;
  18.         invalidate();
  19.     }
  20.  
  21.     @Override
  22.     protected void onDraw(Canvas canvas) {
  23.         if (isInEditMode()) {
  24.             canvas.drawColor(Color.LTGRAY);
  25.             return;
  26.         }
  27.         if (movesSize == 0) return;
  28.         if (palette == null) return;
  29.         if (windowSize == 0) return;
  30.         if (index >= movesSize) return;
  31.  
  32.         int[] colors = palette.getColors();
  33.         int width = canvas.getWidth();
  34.         int cellSize = canvas.getHeight();
  35.  
  36.         int spaceWidth = width / (windowSize + 1);
  37.         List<Integer> subList = moves.subList(index, Math.min(index + windowSize, movesSize));
  38.         int i = 0;
  39.         for (int move : subList) {
  40.             i++;
  41.             paint.setColor(colors[move]);
  42.             final int left = spaceWidth * i - cellSize / 2;
  43.             canvas.drawRect(left, 0, left + cellSize, cellSize, paint);
  44.         }
  45.     }
  46. }

Остальное время занимался исправлением ошибок. Например, не сохранялся экран (игра/конечная заставка), кнопка перехода на новый уровень не учитывала, что после 100-го уровня больше ничего нет, и т.д.

device-2018-10-07-142456.png

2016-12-26.apk


День 12
Уже 12-ый день, а экрана статистики и настроек у меня всё нет, поэтому начал с них.

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

device-2018-10-07-164517.png

В редакторе разметки Android Studio для всех кастомных вьюшек хотелось бы видеть не просто цветные прямоугольники, а что-то более вменяемое, поэтому потратил немного времени на реализацию превью:
  1. // MovesView.init()
  2. if (isInEditMode()) {
  3.     palette(Palette.defaultPalette());
  4.     moves(Arrays.asList(2, 1, 4, 0, 1, 5, 3, 2, 0, 4));
  5. }
  6.  
  7. // BoardView.init()
  8. if (isInEditMode()) {
  9.     Palette palette = Palette.defaultPalette();
  10.     board(Board.create(16).maxColors(6).generate(new BoardGenerator(10L)));
  11.     palette(palette);
  12. }
  13.  
  14. // ButtonsStatView.init()
  15. if (isInEditMode()) {
  16.     palette(Palette.defaultPalette());
  17.     clickCounts(new int[] {33, 50, 75, 12, 18, 21, 0, 13});
  18. }

2018-10-07_14-49-20_1.png 2018-10-07_14-49-20_2.png
Добавил настройки. Обычные настройки, с обычными ListPreference, в которых можно выбрать тему (дневная/ночная) и палитру (стандартная/материал). Эти настройки мне не понравились, нужно было придумать что-то более симпатичное, но пока что голова была занята другим — хотелось создать ещё несколько палитр.

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

Чтобы хоть как-то сделать подбор цветов наглядным и не сойти с ума, я прибегнул к помощи html. Вывел палитру и игровое поле для дневной темы, а рядом для ночной. Затем при помощи dev tools подбирал и менял цвета в палитре. После этого нажимал кнопку и получал список цветов в RGB.

shot-20161227T182751.png

Скрипт здесь https://jsfiddle.net/aNNiMON/tqkynp5z/

Так удалось подобрать ещё 4 палитры. И всё равно, моей ошибкой было то, что я подбирал цвета на ноутбуке, но потом не сверял их на дисплее телефона. На телефоне всё выглядело не совсем так, как задумано. Кое-где разница была в лучшую сторону, а кое-где в худшую.

2016-12-27.apk


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

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

device-2018-10-07-170538.gif


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

device-2018-10-07-172013.png

Кстати, если захотите сделать квадратный View, не пишите так:
  1. @Override
  2. public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
  3.     super.onMeasure(widthMeasureSpec, widthMeasureSpec);
  4. }

Даже если вы принудительно поставите портретный режим, места по высоте может не хватить в контейнере. Поэтому лучше вычислить ширину и высоту, а потом взять минимальное значение:
  1. @Override
  2. public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
  3.     super.onMeasure(widthMeasureSpec, heightMeasureSpec);
  4.     final int width = MeasureSpec.getSize(widthMeasureSpec);
  5.     final int height = MeasureSpec.getSize(heightMeasureSpec);
  6.     final int modeWidth = MeasureSpec.getMode(widthMeasureSpec);
  7.     final int modeHeight = MeasureSpec.getMode(heightMeasureSpec);
  8.     final int size;
  9.     if (modeWidth == MeasureSpec.UNSPECIFIED) {
  10.         size = height;
  11.     } else if (modeHeight == MeasureSpec.UNSPECIFIED) {
  12.         size = width;
  13.     } else {
  14.         size = Math.min(width, height);
  15.     }
  16.     setMeasuredDimension(size, size);
  17. }

Как сделать новые настройки я к вечеру таки придумал, но сделал их лишь на


День 15
device-2018-10-07-173219.gif

Теперь экран настроек лучше соответствовал игре.

В течение дня я ещё не раз запускал игру, чтобы найти какие-нибудь ошибки или недочёты, исправлял их и запускал всё снова. Это было 30 декабря 2016 года, 15-ый день разработки игры подряд. Релизить в этот день я, конечно, не стал, потому что были ещё планы встроить рекламу. Но этим я занимался уже в новом году.


Бонус #0. Релиз
На 16-ый день (но уже не подряд) встроил рекламу AdMob.
На 17-ый добавил возможность покупки отключения рекламы.
И только на 18-ый состоялся релиз версии 1.0.
За вычетом рекламы, игру я всё-таки успел сделать до Нового Года.


Бонус #1. Поддержка Android 2.3
Изначально игра нацеливалась на Android 4.0+, но после релиза предложили добавить поддержку 2.3. Покажу на примере анимаций индикатора, как я решил проблему. Класс Animator, который для этого используется, появился в API 11.

Создал интерфейс AnimatorCompat со всеми используемыми методами:
  1. public interface AnimatorCompat {
  2.     void setDuration(int duration);
  3.     boolean isRunning();
  4.     void end();
  5.     void cancel();
  6.     void setTarget(Object target);
  7.     void start();
  8. }

Для API 11+ создал реализацию, делегирующую объект Animator:
  1. @TargetApi(Build.VERSION_CODES.HONEYCOMB)
  2. public final class AnimatorWrapper implements AnimatorCompat {
  3.  
  4.     private final Animator animator;
  5.  
  6.     public AnimatorWrapper(Animator animator) {
  7.         this.animator = animator;
  8.     }
  9.  
  10.     @Override
  11.     public void setDuration(int duration) {
  12.         animator.setDuration(duration);
  13.     }
  14.  
  15.     @Override
  16.     public boolean isRunning() {
  17.         return animator.isRunning();
  18.     }
  19.  
  20.     @Override
  21.     public void end() {
  22.         animator.end();
  23.     }
  24.  
  25.     @Override
  26.     public void cancel() {
  27.         animator.cancel();
  28.     }
  29.  
  30.     @Override
  31.     public void setTarget(Object target) {
  32.         animator.setTarget(target);
  33.     }
  34.  
  35.     @Override
  36.     public void start() {
  37.         animator.start();
  38.     }
  39. }

А для ранних версий создал пустую реализацию:
  1. package com.annimon.megafloodit.compat;
  2.  
  3. public final class EmptyAnimator implements AnimatorCompat {
  4.     @Override
  5.     public void setDuration(int duration) { }
  6.  
  7.     @Override
  8.     public boolean isRunning() {
  9.         return false;
  10.     }
  11.  
  12.     @Override
  13.     public void end() { }
  14.  
  15.     @Override
  16.     public void cancel() { }
  17.  
  18.     @Override
  19.     public void setTarget(Object target) { }
  20.  
  21.     @Override
  22.     public void start() { }
  23. }

Теперь заменяем все объекты Animator на AnimatorCompat:
  1. private Animator mAnimatorOut;
  2. private Animator mAnimatorIn;
  3. private Animator mImmediateAnimatorOut;
  4. private Animator mImmediateAnimatorIn;
  5. // --
  6. private AnimatorCompat mAnimatorOut;
  7. private AnimatorCompat mAnimatorIn;
  8. private AnimatorCompat mImmediateAnimatorOut;
  9. private AnimatorCompat mImmediateAnimatorIn;

А при создании объекта проверяем версию API и выбираем нужную реализацию:
  1. private AnimatorCompat createAnimatorOut(Context context) {
  2.     if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
  3.         return new AnimatorWrapper(AnimatorInflater.loadAnimator(context, mAnimatorResId));
  4.     } else {
  5.         return new EmptyAnimator();
  6.     }
  7. }

Именно так и работают большинство Compat-классов в Android Support Library.


Бонус #2. Что было ещё добавлено и как?
На 21 день состоялся релиз версии 1.1. За два дня сделал редизайн экрана выбора режимов. Теперь они выбираются перелистыванием во FragmentPagerAdapter. Вынес быструю игру в первый пункт меню и добавил возможность настроить количество цветов и размер игрового поля.

device-2018-10-07-183210.png

А ещё добавил обучение, о реализации которого расскажу, пожалуй, в отдельной статье.

Мне не раз говорили об анимациях, поэтому появилась идея сделать анимацию заливки в виде RippleAnimation. С этим была наибольшая сложность, я никак не мог придумать, как это лучше всего сделать. Хотел сделать слоями: рисовать сначала тот цвет, который мы заливаем, потом постепенно анимировать круг новым цветом, а в конце рисовать всё поле, но уже без первых двух цветов. Так я и сделал бы, если бы вовремя не открыл для себя clipPath.

Всё оказалось достаточно простым. Пройтись по игровому полю и добавить в Path клетки с тем цветом, который мы будем заливать. Затем вызвать clipPath с заданным Path, а потом рисовать круг.
  1. public void draw(Canvas canvas, Paint paint,
  2.                  int boardSize, int cellSize,
  3.                  int xStart, int yStart) {
  4.     final int size = currentArea.length;
  5.     animationFillMask.reset();
  6.     for (int y = 0; y < size; y++) {
  7.         for (int x = 0; x < size; x++) {
  8.             if (!currentArea[y][x]) continue;
  9.             int left = x * cellSize + xStart;
  10.             int top = y * cellSize + yStart;
  11.             animationFillMask.addRect(left, top, left + cellSize, top + cellSize, Path.Direction.CW);
  12.         }
  13.     }
  14.     animationFillMask.close();
  15.  
  16.     int saved = canvas.save();
  17.     canvas.clipPath(animationFillMask);
  18.     canvas.drawCircle(0, 0, (float) (frame * (boardSize * 1.5) / maxFrames), paint);
  19.     canvas.restoreToCount(saved);
  20. }

Однако этот способ не работает в аппаратном ускорении на Android API меньше 18, так что нужно ставить программный режим для отображения View.

flood-it-material.gif

Дальше захотелось добавить пасхалки и тут не обошлось без пиксель арта. Говорить о них я так же не буду, просто покажу одну из них:
device-2018-10-07-192353.png

В 25-ый день вышел релиз 1.2, содержащий всё вышеперечисленное.

Затем я приступил к мультиплееру. Ввёл интерфейс MoveStrategy:
  1. public interface MoveStrategy {
  2.     void onSuccessMove();
  3.     void onPreColorButtonClick();
  4.     void onMoveEnd();
  5.     void onFillComplete();
  6.     void onBoardCompleted();
  7. }

И две реализации. PlayerMove для игры друг с другом:
  1. public class PlayerMove implements MoveStrategy {
  2.  
  3.     @Override
  4.     public void onPreColorButtonClick() {
  5.         type.getViewsPack().boardView.clearCurrentArea();
  6.     }
  7.  
  8.     @Override
  9.     public void onFillComplete() { }
  10.  
  11.     @Override
  12.     public void onSuccessMove() {
  13.         type.switchFirstPlayerMove();
  14.     }
  15.  
  16.     @Override
  17.     public void onMoveEnd() {
  18.         type.getViewsPack().boardView.rotate(RotatableBoard.Rotation.R180, null);
  19.         type.updateMovesCounter();
  20.     }
  21.  
  22.     @Override
  23.     public void onBoardCompleted() {
  24.         type.statistics()
  25.                 .updateGamesPlayedCount();
  26.     }
  27. }

и AndroidMove для игры против андроида:
  1. public class AndroidMove implements MoveStrategy {
  2.  
  3.     @Override
  4.     public void onPreColorButtonClick() { }
  5.  
  6.     @Override
  7.     public void onFillComplete() {
  8.         final int last = type.getBoard().getBoardSize() - 1;
  9.         final List<Integer> possibleMoves = new ArrayList<>(type.getMaxColors());
  10.         for (int i = 0; i < type.getMaxColors(); i++) {
  11.             possibleMoves.add(i);
  12.         }
  13.         // Android can't use current color and colot of his opponent
  14.         possibleMoves.remove((Integer) type.getBoard().getCellAt(0, 0));
  15.         possibleMoves.remove((Integer) type.getBoard().getCellAt(last, last));
  16.  
  17.         final List<Pair<Integer, Integer>> ranks = new ArrayList<>(possibleMoves.size());
  18.         for (Integer possibleMove : possibleMoves) {
  19.             int[][] board = type.getBoard().getBoardCopy();
  20.             Board.fill(possibleMove, last, last, board, null);
  21.             final int count = Board.fill(-1, last, last, board, null);
  22.  
  23.             board = type.getBoard().getBoardCopy();
  24.             Board.fill(possibleMove, 0, 0, board, null);
  25.             final int countOpposite = Board.fill(-1, 0, 0, board, null);
  26.  
  27.             ranks.add(Pair.create(possibleMove, count + countOpposite));
  28.         }
  29.         Collections.sort(ranks, new Comparator<Pair<Integer, Integer>>() {
  30.             @Override
  31.             public int compare(Pair<Integer, Integer> o1, Pair<Integer, Integer> o2) {
  32.                 return o2.second.compareTo(o1.second);
  33.             }
  34.         });
  35.  
  36.         final int androidMove = ranks.get(0).first;
  37.  
  38.         type.getViewsPack().boardView.clearCurrentArea();
  39.         type.getViewsPack().boardView.fillByAndroid(androidMove);
  40.         type.updateMovesCounter();
  41.  
  42.         if (type.getBoard().isCompleted()) {
  43.             // Android won
  44.             type.statistics()
  45.                     .updateGamesPlayedCount()
  46.                     .updateLossCount();
  47.             type.onBoardCompleted();
  48.         }
  49.     }
  50.  
  51.     @Override
  52.     public void onSuccessMove() { }
  53.  
  54.     @Override
  55.     public void onMoveEnd() { }
  56.  
  57.     @Override
  58.     public void onBoardCompleted() {
  59.         // Player won
  60.         type.statistics()
  61.                 .updateGamesPlayedCount()
  62.                 .updateWinsCount();
  63.     }
  64. }

Алгоритм хода андроида не очень сложный. Он просчитывает варианты только на один ход вперёд. Для каждого возможного цвета (а их всегда равно MAX_COLORS-2) считает количество залитых клеток для себя, а затем для игрока и выбирает лучший цвет в свою пользу.
При таких условиях, есть одна маленькая особенность: если алгоритм посчитает, что на следующем ходу у игрока есть такой цвет, который даст ему преимущество, то андроид начнёт западлить и сам зальёт своё поле этим цветом. Теперь, зная это, попробуйте поиграть против андроида и вы поймёте. Иногда это выглядит забавно)

На 34-ый день вышел релиз 1.3, в котором добавился мультиплеер, описанный выше.


Бонус #3. Как спустя почти два года я сумел воссоздать историю разработки проекта?
Думаю, ответ на этот вопрос тоже будет интересен.

Во-первых, git. По нему очень здорово восстанавливать историю, так как видно весь код, который был написан.

Во-вторых, я начинал писать игру уже с мыслями о том, что когда-нибудь напишу о ходе разработки, поэтому в конце каждого дня записывал в блокнот всё нужное. Это были просто небольшие заметки о проделанной работе, чтобы знать, в какой день чем была забита голова, что хотел сделать и что в итоге сделал. И если кое-что git мог перенести не на тот день, в котором это было сделано, то заметка восстановит справедливость. Например, вечером я сделал переключение тем, закоммитил, а потом утром немного доработал и сделал amend commit. Теперь весь коммит будет уже сегодняшним. Но вчерашняя запись о том, что смену тем я таки сделал, поможет в будущем восстановить порядок действий.

В-третьих, канбан-доски. Где-то на третий или четвёртый день я вспомнил о их существовании и решил применить. Очень помогло, всем советую.

Screenshot_2018-10-07.png

История коммитов проекта


Заключение
Конечно, то, что я написал в этой статье — лишь поверхностное описание. Кроме кода, задач и идей есть ещё и азарт, злость на жрущий Gradle, собирающий проект по две-четыре минуты на моём железе, усталость, двукратное удаление темы на 4pda по этой игре, потому что скачиваний было недостаточно (во второй раз я пытался накрутить, было весело, но тоже не помогло), интерес получить новый опыт, трепетное заполнение данных при публикации игры на Google Play, а также какая-то привязанность, что ли. В общем что-то, что заставляло меня на протяжении полутора лет вспоминать об игре и о том, что я хотел написать о процессе её разработки и в конце концов заставило написать эту статью.

Всем, кто дочитал, спасибо. Исходники (пока что?) не выкладываю, но если нужно конкретнее описать какую-то реализацию из игры, пишите в комментариях, я добавлю.

Ну и, наконец, игра на :android: Google Play.
  • +8
  • views 5009