Как написать игру под Android за 15 дней. История создания Mega Flood-It. Часть первая
от aNNiMON
Ещё в далёком декабре 2016-го я начал писать игру под Android и параллельно описывал ход разработки, чтобы в один прекрасный момент (сегодня) восстановить события и поведать о том, как пришла идея игры, с чего я начал разработку, какие трудности возникли и что приходилось делать, чтобы облегчить себе работу.
День 0
15 декабря 2016 года. После обеда захотелось немного полежать и скоротать время за игрой на телефоне. Динамические игры я не очень люблю, хотелось чего-то простого, ведь мой любимый жанр игр — головоломки и логические игры. И тут я вспомнил про старую добрую Flood-It. То, что нужно!
Первая игра в выдаче маркета была неплохой, но интерфейс был не очень приятным, попросту уставали глаза. Стали интересны другие реализации. Вторая игра была с обилием рекламы и ничего хорошего из себя не представляла. В третьей с графикой было всё нормально, но после каждого проигрыша прогресс начисто сбивался и нужно было проходить одни и те же уровни заново. Игры на движках типа Unity я даже не устанавливал, во-первых, писать логическую игру на таком движке это явно перебор, а во-вторых, на моей Sony Xperia Pro не было памяти для установки таких больших игр.
В общем, игр Flood-It в маркете было много, но той, которую требовала душа, не было. И тогда я решил, что сделаю лучшую игру Flood-It: огромное множество уровней, смена стилей, ночная тема и статистика — вот те самые важные для моей души критерии.
День 1
Разработка началась с запуска Android Studio и создания проекта. Уже тогда пронеслась мысль, а успею ли я реализовать задуманное до нового года? В запасе было ещё две недели, так что я не стал расписывать и продумывать всё до мелочей, а сломя голову принялся писать код, проектируя на ходу. Идея была следующей: как можно быстрее написать рабочий прототип, а дальше уже просто наращивать функционал.
Начать решил с модели, чтобы всё работало просто "в памяти", а потом уже к этой модели прикрутить вид. Создал класс Board — игровая доска:
Здесь можно задать размер доски и количество цветов. Есть метод заливки заданным цветом, который потом и будет вызываться при каждом ходе игрока. Метод возвращает количество залитых клеток не просто так, это нужно было для статистики, которая была одной из ключевых фишек моей игры. Алгоритм заливки пока что рекурсивный, думал, что Android справится. Генерированием игрового поля занимается класс BoardGenerator:
Пока что это просто обёртка над классом Random, но проще оперировать сущностью BoardGenerator, чем Random. Ещё я создал класс Palette, обозначающий палитру, которую затем можно было бы менять в настройках:
Этих классов уже было достаточно, чтобы можно было играть, вызывая методы, так что я принялся за вид. Экран должен делиться на две логические части: игровое поле — квадрат размером NxN и панель с цветными кнопками, которые будет нажимать игрок, делая ход.
Игровое поле я сделал в виде вьюшки на канвасе:
Просто отрисовка клеток, цвет которых берётся из класса Palette.
За игровые кнопки у меня отвечал другой класс — ButtonsManager, состоящий из maxColors расположенных последовательно View во ViewGroup:
Теперь кое-как связываем модель и вид:
И получаем самый что ни на есть простой, но зато работающий прототип игры за один день. И ничего, что после заливки всего поля, игру нужно перезапускать.
2016-12-16.apk
День 2
Первой проблемой, с которой я столкнулся был вылет игры в некоторых ситуациях. Небольшое подвисание, а затем большой стектрейс с повторяющимися строками говорил о том, что где-то переполнение стека. Такое место у меня было пока что одно — рекурсия в методе заливки.
И, верно, проблема была. Заливка не учитывала уже залитые клетки и могла на последующих итерациях заливать их снова и снова. Решается дополнительным массивом boolean[][], где отмечаются залитые клетки:
Игре не хватало проверки выигрышной ситуации, когда всё поле залито одним цветом. Поэтому добавился метод Board.isCompleted():
По правилам игры заливать поле игрок начинает с верхнего левого угла, то есть намного чаще будут залиты одним цветом верхние ряды, чем нижние. Значит, быстрее проверять поле не сверху вниз, а снизу вверх.
После этого я приступил ко второй ключевой особенности игры — ночной теме. В палитре теперь хранились цвета для дневной и для ночной темы, чтобы можно было быстро между ними переключаться, не загружая палитру заново:
При этом сама тема приложения пока что не меняется, просто задаётся светлый или тёмный фон при переключении.
Также поработал над кнопками управления, сделав их квадратными.
2016-12-17.apk
День 3
Наконец-то дошли руки переписать рекурсивный алгоритм заливки на нерекурсивный с использованием Deque:
Теперь уж точно нигде переполнение стека не произойдёт. Также добавил экран меню, в котором работает только первый пункт:
День 4
Добавил экран выбора режима игры c отображением размерности поля, количества цветов и картинки-превью. Размерность: 8x8, 12x12, 16x16, 20x20 и 24x24. Для каждой размерности поля есть три режима: 4 цвета, 6 цветов и 8 цветов. Итого 15 режимов.
По крайней мере, уже можно выбрать тот режим, который хочется поиграть.
2016-12-19.apk
День 5
Новый день — новый экран. На этот раз добавил выбор уровня в указанном режиме. У уровня есть такие параметры:
Номер уровня, их будет 100 в каждом из 15 режимов.
Максимально допустимое число ходов для уровня. Если игрок превышает это число, то уровень считается не пройденным. Также хранится лучший результат игрока для этого уровня.
Ресурс фона — метка уровня. Если уровень пройден или открыт, то фон отображается зелёным, если не пройден — красным, а если пока недоступен — серым.
Наконец, хранится доступность уровня. Игроку не сразу доступны все уровни, они открываются по мере прохождения игры.
Также сделал сохранение прогресса игры. Просто в файл пишем количество ходов игрока для всех уровней, которые были сыграны.
Теперь в игре было 1500 уровней. На самом деле, их можно сделать гораздо больше и вот почему. В первый день я создавал класс BoardGenerator, который являлся простой обёрткой над Random и принимал seed:
Если для этого генератора задать seed = 1, то всегда будет генерироваться один и тот же уровень. Если 2, то уже другой. И так далее. А чисел в long ведь гораздо больше 1500! Вот и получается, что в любой момент игру можно расширить до такого количества уровней, что никто и играть не захочет (на самом деле при 1500 я предполагал, что тоже будет такой эффект).
В игре теперь поле генерировалось так:
Если выбирался именно уровень, а не просто режим случайной игры, то seed равнялся номеру уровня.
2016-12-20.apk
Пока что оставался один нерешённый момент, а именно level.setMaxMoves(25). Эта строчка как будто насмехалась: Любишь уровни на основе рандома генерировать, люби и оптимальное число ходов высчитывать. И тогда я закрыл Android Studio и открыл NetBeans, чтобы написать на Java SE небольшой класс, но тут время перевалило за полночь, а это уже:
День 6
Когда-то я писал на Хабре статью про создание бота для игры Flood-It, который находил поле и сам нажимал на нужные кнопки. На основе этого кода я сделал solver:
Класс Random при передаче одинаковых сидов выдаёт одни и те же последовательности как в Android, так и в Java SE. Это значит, что можно генерировать одинаковые уровни на обеих платформах и расчёт оптимального количества ходов можно сделать на ПК. Этим я и занимался всё утро.
Количество ходов на уровень сохраняется в файле. Нулевой байт не трогаем, в первом хранится число ходов для первого уровня, во втором для второго и так далее. Получаем 15 файлов по 100 байт каждый.
У алгоритма расчёта есть параметр — глубина просмотра ходов. Чем выше значение, тем оптимальнее просчитываются ходы. Для первых 20 уровней я взял минимальную глубину, чтобы число ходов было с запасом. Дальше, до 60-го уровня глубина была уже выше. До 90 ещё выше и последние 10 уровней были вообще для мегамозгов (на самом деле нет, но об этом позже). Дополнительно, каждый десятый уровень был с повышенной на одно значение сложностью. Это всё видно в последних двух методах stepsForLevel и stepsForPercent.
На деле же оказалось, что высокая глубина просчёта иногда давала не совсем оптимальный результат по сравнению с меньшей глубиной. Но об этом я узнал намного позже, когда все файлы уже были сгенерированы, а перегенерировать не хотелось. В итоге этот промах стал "приятной фичей для тех, кто прошел 90 уровней".
Во второй половине дня немного изменил стиль выбора режима и уровня.
2016-12-21.apk
День 7
И был день, и была но… Ой, ночи у нас пока не было. Самое время это исправить. Когда стили для ночной темы были готовы и кнопка переключения наконец-то переключала тему, а не меняла фон, я столкнулся с небольшой проблемой. Если в игре сменить тему, а потом вернуться назад, то предыдущая активити всё ещё будет с изначальной темой. Поэтому при переходе между активити нужно проверять, совпадает ли тема в настройках с текущей темой в активити.
Теперь, наследуясь от класса ThemeableActivity, мы получаем автоматическую поддержку тем.
Также сделал учёт статистики: количество нажатий, число игр, выигрышей и рестартов, число залитых клеток.
2016-12-22.apk
Так прошла неделя. Теперь, имея на руках работающую игру, оставалось только добавлять функционал и придумывать новые идеи.
В следующей части: как подобрать цвета для клеток и не сойти с ума, конечная заставка, история ходов и многое другое.
Следующая статья →День 0
15 декабря 2016 года. После обеда захотелось немного полежать и скоротать время за игрой на телефоне. Динамические игры я не очень люблю, хотелось чего-то простого, ведь мой любимый жанр игр — головоломки и логические игры. И тут я вспомнил про старую добрую Flood-It. То, что нужно!
Первая игра в выдаче маркета была неплохой, но интерфейс был не очень приятным, попросту уставали глаза. Стали интересны другие реализации. Вторая игра была с обилием рекламы и ничего хорошего из себя не представляла. В третьей с графикой было всё нормально, но после каждого проигрыша прогресс начисто сбивался и нужно было проходить одни и те же уровни заново. Игры на движках типа Unity я даже не устанавливал, во-первых, писать логическую игру на таком движке это явно перебор, а во-вторых, на моей Sony Xperia Pro не было памяти для установки таких больших игр.
В общем, игр Flood-It в маркете было много, но той, которую требовала душа, не было. И тогда я решил, что сделаю лучшую игру Flood-It: огромное множество уровней, смена стилей, ночная тема и статистика — вот те самые важные для моей души критерии.
День 1
Разработка началась с запуска Android Studio и создания проекта. Уже тогда пронеслась мысль, а успею ли я реализовать задуманное до нового года? В запасе было ещё две недели, так что я не стал расписывать и продумывать всё до мелочей, а сломя голову принялся писать код, проектируя на ходу. Идея была следующей: как можно быстрее написать рабочий прототип, а дальше уже просто наращивать функционал.
Начать решил с модели, чтобы всё работало просто "в памяти", а потом уже к этой модели прикрутить вид. Создал класс Board — игровая доска:
- public class Board {
- private final int[][] board;
- private int maxColors;
- public Board generate(BoardGenerator generator) {
- generator.reinit();
- final int size = board.length;
- for (int y = 0; y < size; y++) {
- for (int x = 0; x < size; x++) {
- board[y][x] = generator.generate(maxColors);
- }
- }
- return this;
- }
- public int getCellAt(int x, int y) { /* .. */ }
- public Board updateAt(int x, int y, int color) { /* .. */ }
- public int fill(int color) {
- int cell = getCellAt(0, 0);
- if (cell != color) {
- return fill(0, 0, cell, color);
- }
- return 0;
- }
- private int fill(int x, int y, int prevColor, int color) {
- if ( (x < 0) || (y < 0) || (x >= board.length) || (y >= board.length) ) {
- return 0;
- }
- int filled = 0;
- if (board[x][y] == prevColor) {
- board[x][y] = color;
- filled++;
- filled += fill(x - 1, y, prevColor, color);
- filled += fill(x + 1, y, prevColor, color);
- filled += fill(x, y - 1, prevColor, color);
- filled += fill(x, y + 1, prevColor, color);
- }
- return filled;
- }
- }
Здесь можно задать размер доски и количество цветов. Есть метод заливки заданным цветом, который потом и будет вызываться при каждом ходе игрока. Метод возвращает количество залитых клеток не просто так, это нужно было для статистики, которая была одной из ключевых фишек моей игры. Алгоритм заливки пока что рекурсивный, думал, что Android справится. Генерированием игрового поля занимается класс BoardGenerator:
- public class BoardGenerator {
- private final long seed;
- private Random random;
- public BoardGenerator(long seed) {
- this.seed = seed;
- reinit();
- }
- public void reinit() {
- random = new Random(seed);
- }
- public int generate(int max) {
- return random.nextInt(max);
- }
- }
Пока что это просто обёртка над классом Random, но проще оперировать сущностью BoardGenerator, чем Random. Ещё я создал класс Palette, обозначающий палитру, которую затем можно было бы менять в настройках:
- public class Palette {
- private final int[] colors;
- public int getColor(int index) { /* .. */ }
- }
Этих классов уже было достаточно, чтобы можно было играть, вызывая методы, так что я принялся за вид. Экран должен делиться на две логические части: игровое поле — квадрат размером NxN и панель с цветными кнопками, которые будет нажимать игрок, делая ход.
Игровое поле я сделал в виде вьюшки на канвасе:
- public class BoardView extends View {
- private Board board;
- private Palette palette;
- @Override
- protected void onDraw(Canvas canvas) {
- if (isInEditMode()) {
- canvas.drawColor(Color.LTGRAY);
- return;
- }
- 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)]);
- int left = x * cellSize;
- int top = y * cellSize;
- canvas.drawRect(left, top, left + cellSize, top + cellSize, paint);
- }
- }
- }
- }
За игровые кнопки у меня отвечал другой класс — ButtonsManager, состоящий из maxColors расположенных последовательно View во ViewGroup:
- public class ButtonsManager {
- private static final int FIRST_ID = 1000;
- private View[] buttons;
- private ButtonsManager(ViewGroup layout) {
- int childCount = layout.getChildCount();
- buttons = new View[childCount];
- for (int i = 0; i < childCount; i++) {
- buttons[i] = layout.getChildAt(i);
- buttons[i].setId(FIRST_ID + i);
- }
- }
- public ButtonsManager maxColors(int max) {
- for (int i = 0; i < max; i++) {
- buttons[i].setVisibility(View.VISIBLE);
- }
- for (int i = max; i < buttons.length; i++) {
- buttons[i].setVisibility(View.GONE);
- }
- return this;
- }
- public ButtonsManager palette(Palette palette) {
- for (int i = 0; i < buttons.length; i++) {
- buttons[i].setBackgroundColor(palette.getColor(i));
- }
- return this;
- }
- public ButtonsManager listener(ColorButtonListener listener) {
- View.OnClickListener clickListener = v -> {
- listener.onClick(v, v.getId() - FIRST_ID);
- };
- for (View button : buttons) {
- button.setOnClickListener(clickListener);
- }
- return this;
- }
- }
- public interface ColorButtonListener {
- void onClick(View v, int index);
- }
Теперь кое-как связываем модель и вид:
- public class MainActivity extends AppCompatActivity {
- @Override
- protected void onCreate(Bundle savedInstanceState) {
- final int maxColors = 6;
- BoardGenerator generator = new BoardGenerator();
- Palette palette = new Palette(
- getResources().getIntArray(R.array.standardPalette));
- Board board = Board.create(8).maxColors(maxColors).generate(generator);
- BoardView boardView = (BoardView) findViewById(R.id.board);
- boardView.board(board)
- .palette(palette);
- ButtonsManager.from(findViewById(R.id.buttonsLayout))
- .maxColors(maxColors)
- .palette(palette)
- .listener((view, index) -> {
- board.fill(index);
- boardView.invalidate();
- }
- });
- }
- }
2016-12-16.apk
День 2
Первой проблемой, с которой я столкнулся был вылет игры в некоторых ситуациях. Небольшое подвисание, а затем большой стектрейс с повторяющимися строками говорил о том, что где-то переполнение стека. Такое место у меня было пока что одно — рекурсия в методе заливки.
И, верно, проблема была. Заливка не учитывала уже залитые клетки и могла на последующих итерациях заливать их снова и снова. Решается дополнительным массивом boolean[][], где отмечаются залитые клетки:
- public int fill(int color) {
- int cell = getCellAt(0, 0);
- if (cell != color) {
- int size = board.length;
- boolean[][] visited = new boolean[size][size];
- return fill(0, 0, cell, color, visited);
- }
- return 0;
- }
- private int fill(int x, int y, int prevColor, int color, boolean[][] visited) {
- if ( (x < 0) || (y < 0) || (x >= board.length) || (y >= board.length)
- || visited[y][x]) {
- return 0;
- }
- int filled = 0;
- if (board[y][x] == prevColor) {
- board[y][x] = color;
- visited[y][x] = true;
- filled++;
- filled += fill(x - 1, y, prevColor, color, visited);
- filled += fill(x + 1, y, prevColor, color, visited);
- filled += fill(x, y - 1, prevColor, color, visited);
- filled += fill(x, y + 1, prevColor, color, visited);
- }
- return filled;
- }
Игре не хватало проверки выигрышной ситуации, когда всё поле залито одним цветом. Поэтому добавился метод Board.isCompleted():
- public boolean isCompleted() {
- final int size = board.length;
- final int color = board[0][0];
- for (int y = size - 1; y >= 0; y--) {
- for (int x = 0; x < size; x++) {
- if (board[y][x] != color) return false;
- }
- }
- return true;
- }
После этого я приступил ко второй ключевой особенности игры — ночной теме. В палитре теперь хранились цвета для дневной и для ночной темы, чтобы можно было быстро между ними переключаться, не загружая палитру заново:
- public class Palette {
- private final int[] dayColors;
- private final int[] nightColors;
- public int getColor(int index) {
- if (isNight) return getNightColor(index);
- else return getDayColor(index);
- }
- public int getDayColor(int index) {
- if (index < 0 || index >= dayColors.length) {
- return -1;
- }
- return dayColors[index];
- }
- public int getNightColor(int index) {
- if (index < 0 || index >= nightColors.length) {
- return -1;
- }
- return nightColors[index];
- }
- }
При этом сама тема приложения пока что не меняется, просто задаётся светлый или тёмный фон при переключении.
Также поработал над кнопками управления, сделав их квадратными.
2016-12-17.apk
День 3
Наконец-то дошли руки переписать рекурсивный алгоритм заливки на нерекурсивный с использованием Deque:
- public int fill(int color) {
- final int prevColor = getCellAt(0, 0);
- if (prevColor != color) {
- final int size = board.length;
- boolean[][] visited = new boolean[size][size];
- Deque<Point> deque = new ArrayDeque<>(size * 2);
- deque.add(new Point(0, 0));
- int filled = 0;
- while (!deque.isEmpty()) {
- Point p = deque.remove();
- int x = p.x;
- int y = p.y;
- visited[y][x] = true;
- if (board[y][x] != prevColor) {
- continue;
- }
- board[y][x] = color;
- filled++;
- for (int i = -1; i <= 1; i += 2) {
- final int newX = x + i;
- if ( (0 <= newX) && (newX < size) && !visited[y][newX]) {
- deque.add(new Point(newX, y));
- }
- final int newY = y + i;
- if ( (0 <= newY) && (newY < size) && !visited[newY][x]) {
- deque.add(new Point(x, newY));
- }
- }
- }
- return filled;
- }
- return 0;
- }
Теперь уж точно нигде переполнение стека не произойдёт. Также добавил экран меню, в котором работает только первый пункт:
День 4
Добавил экран выбора режима игры c отображением размерности поля, количества цветов и картинки-превью. Размерность: 8x8, 12x12, 16x16, 20x20 и 24x24. Для каждой размерности поля есть три режима: 4 цвета, 6 цветов и 8 цветов. Итого 15 режимов.
- @Override
- protected void onCreate(Bundle savedInstanceState) {
- setContentView(R.layout.mode_selector);
- final Random rnd = new Random();
- palette = new Palette(
- getResources().getIntArray(R.array.standardPalette),
- getResources().getIntArray(R.array.standardPalette_dark));
- adapter = new ModeAdapter(this);
- for (int boardSize = 8; boardSize <= 24; boardSize += 4) {
- for (int maxColors = 4; maxColors <= 8; maxColors += 2) {
- GameMode mode = new GameMode(boardSize, maxColors);
- mode.setLevelsCount(100);
- mode.setLevel(rnd.nextInt(100));
- mode.setPreview(generatePreview(boardSize, maxColors));
- adapter.add(mode);
- }
- }
- listView.setAdapter(adapter);
- listView.setOnItemClickListener(this);
- }
- @Override
- public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
- GameMode mode = adapter.getItem(position);
- startActivity(GameActivity.newIntent(this, mode.getBoardSize(), mode.getMaxColors()));
- }
- private Bitmap generatePreview(int boardSize, int maxColors) {
- float density = getResources().getDisplayMetrics().density;
- int size = (int) (100 * density);
- Bitmap bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
- Canvas canvas = new Canvas(bitmap);
- Random random = new Random(boardSize * 100 + maxColors);
- Paint paint = new Paint();
- int cellSize = size / boardSize;
- int yStart = (size - boardSize * cellSize) / 2;
- for (int y = 0; y < boardSize; y++) {
- for (int x = 0; x < boardSize; x++) {
- int color = palette.getColor(random.nextInt(maxColors));
- paint.setColor(color);
- int left = x * cellSize;
- int top = y * cellSize + yStart;
- canvas.drawRect(left, top, left + cellSize, top + cellSize, paint);
- }
- }
- return bitmap;
- }
По крайней мере, уже можно выбрать тот режим, который хочется поиграть.
2016-12-19.apk
День 5
Новый день — новый экран. На этот раз добавил выбор уровня в указанном режиме. У уровня есть такие параметры:
- public class Level {
- public static int MAX_LEVELS = 100;
- private final int number;
- private int maxMoves, playerMoves;
- private int backgroundResource;
- private boolean isAvailable;
- }
Максимально допустимое число ходов для уровня. Если игрок превышает это число, то уровень считается не пройденным. Также хранится лучший результат игрока для этого уровня.
Ресурс фона — метка уровня. Если уровень пройден или открыт, то фон отображается зелёным, если не пройден — красным, а если пока недоступен — серым.
Наконец, хранится доступность уровня. Игроку не сразу доступны все уровни, они открываются по мере прохождения игры.
Также сделал сохранение прогресса игры. Просто в файл пишем количество ходов игрока для всех уровней, которые были сыграны.
- private static void writeLevelsCompletion(Context context, String filename,
- List<Level> levels) throws IOException {
- DataOutputStream dos = new DataOutputStream(
- context.openFileOutput(filename, Context.MODE_PRIVATE)
- );
- int openedLevels = levels.size();
- dos.writeInt(openedLevels);
- for (int i = 0; i < openedLevels; i++) {
- Level level = levels.get(i);
- dos.writeInt(level.getPlayerMoves());
- }
- dos.flush();
- dos.close();
- }
- private static List<Level> readLevelsCompletion(Context context, String filename)
- throws IOException {
- if (context.getFileStreamPath(filename).exists()) {
- DataInputStream dis = new DataInputStream(context.openFileInput(filename));
- int openedLevels = dis.readInt();
- List<Level> levels = new ArrayList<>(openedLevels);
- for (int i = 1; i <= openedLevels; i++) {
- Level level = new Level(i);
- level.setAvailable(true);
- level.setPlayerMoves(dis.readInt());
- levels.add(level);
- }
- dis.close();
- return levels;
- }
- return new ArrayList<>();
- }
Теперь в игре было 1500 уровней. На самом деле, их можно сделать гораздо больше и вот почему. В первый день я создавал класс BoardGenerator, который являлся простой обёрткой над Random и принимал seed:
- public BoardGenerator(long seed) {
- this.seed = seed;
- reinit();
- }
Если для этого генератора задать seed = 1, то всегда будет генерироваться один и тот же уровень. Если 2, то уже другой. И так далее. А чисел в long ведь гораздо больше 1500! Вот и получается, что в любой момент игру можно расширить до такого количества уровней, что никто и играть не захочет (на самом деле при 1500 я предполагал, что тоже будет такой эффект).
В игре теперь поле генерировалось так:
- private void newBoard() {
- generator = new BoardGenerator(getSeed());
- board = Board.create(boardSize)
- .maxColors(maxColors)
- .generate(generator);
- boardView.board(board)
- .palette(palette);
- buttonsManager = ButtonsManager.from(buttonsLayout)
- .maxColors(maxColors)
- .palette(palette)
- .listener(this);
- }
- private long getSeed() {
- return seed == RANDOM_SEED ? System.currentTimeMillis() : seed;
- }
Если выбирался именно уровень, а не просто режим случайной игры, то seed равнялся номеру уровня.
2016-12-20.apk
- private void updateLevels() {
- adapter.clear();
- List<Level> openedLevels = Prefs.readLevelsCompletion(this, boardSize, maxColors);
- int openedLevelsCount = openedLevels.size();
- for (int i = 1; i <= Level.MAX_LEVELS; i++) {
- Level level = new Level(i);
- level.setMaxMoves(25);
- if (i - 1 < openedLevelsCount) {
- Level openedLevel = openedLevels.get(i - 1);
- level.setAvailable(true);
- level.setPlayerMoves(openedLevel.getPlayerMoves());
- } else {
- // Next after last opened level available always
- level.setAvailable((i - 1 == openedLevelsCount));
- level.setPlayerMoves(0);
- }
- if (level.isAvailable()) {
- level.setBackgroundResource(level.getPlayerMoves() > level.getMaxMoves()
- ? R.drawable.level_item_lost
- : R.drawable.level_item_normal);
- } else {
- level.setBackgroundResource(R.drawable.level_item_disabled);
- }
- adapter.add(level);
- }
- adapter.notifyDataSetChanged();
- }
Пока что оставался один нерешённый момент, а именно level.setMaxMoves(25). Эта строчка как будто насмехалась: Любишь уровни на основе рандома генерировать, люби и оптимальное число ходов высчитывать. И тогда я закрыл Android Studio и открыл NetBeans, чтобы написать на Java SE небольшой класс, но тут время перевалило за полночь, а это уже:
День 6
Когда-то я писал на Хабре статью про создание бота для игры Flood-It, который находил поле и сам нажимал на нужные кнопки. На основе этого кода я сделал solver:
- public class FloodItSolver {
- private void process() {
- int minBoardSize = 8, maxBoardSize = 24, stepBoardSize = 4;
- int minColors = 4, maxColors = 8, stepColors = 2;
- int size = ((maxBoardSize - minBoardSize) / stepBoardSize);
- Map<String, int[]> levelsCount = new LinkedHashMap<>(size + 2);
- for (int boardSize = minBoardSize; boardSize <= maxBoardSize; boardSize += stepBoardSize) {
- for (int colors = minColors; colors <= maxColors; colors += stepColors) {
- GameMode mode = new GameMode(boardSize, colors);
- int[] minMoves = processMode(mode);
- levelsCount.put(mode.toString(), minMoves);
- saveLevelsCount(mode, minMoves);
- }
- }
- }
- private void saveLevelsCount(GameMode mode, int[] minMoves) {
- File file = new File(mode.toString());
- try (DataOutputStream dos = new DataOutputStream(new FileOutputStream(file))) {
- dos.writeByte(0);
- for (int moves : minMoves) {
- dos.writeByte(moves);
- }
- dos.flush();
- } catch (IOException ex) {
- }
- }
- private int[] processMode(GameMode mode) {
- int[] minMoves = new int[MAX_LEVELS];
- for (int level = 1; level <= 100; level++) {
- int moves = processMovesCalculation(mode, level);
- minMoves[level - 1] = moves;
- System.out.format("Level: %d, moves: %d%n", level, moves);
- }
- return minMoves;
- }
- private int stepsForLevel(int level) {
- int percent = ((level - 1) * 100 / MAX_LEVELS);
- int steps = stepsForPercent(percent);
- if (level % 10 == 0) {
- return steps + 1;
- }
- return steps;
- }
- private int stepsForPercent(int percent) {
- if (percent < 20) return 1;
- if (percent < 60) return 2;
- if (percent < 90) return 3;
- return 4;
- }
- }
Класс Random при передаче одинаковых сидов выдаёт одни и те же последовательности как в Android, так и в Java SE. Это значит, что можно генерировать одинаковые уровни на обеих платформах и расчёт оптимального количества ходов можно сделать на ПК. Этим я и занимался всё утро.
Количество ходов на уровень сохраняется в файле. Нулевой байт не трогаем, в первом хранится число ходов для первого уровня, во втором для второго и так далее. Получаем 15 файлов по 100 байт каждый.
У алгоритма расчёта есть параметр — глубина просмотра ходов. Чем выше значение, тем оптимальнее просчитываются ходы. Для первых 20 уровней я взял минимальную глубину, чтобы число ходов было с запасом. Дальше, до 60-го уровня глубина была уже выше. До 90 ещё выше и последние 10 уровней были вообще для мегамозгов (на самом деле нет, но об этом позже). Дополнительно, каждый десятый уровень был с повышенной на одно значение сложностью. Это всё видно в последних двух методах stepsForLevel и stepsForPercent.
На деле же оказалось, что высокая глубина просчёта иногда давала не совсем оптимальный результат по сравнению с меньшей глубиной. Но об этом я узнал намного позже, когда все файлы уже были сгенерированы, а перегенерировать не хотелось. В итоге этот промах стал "приятной фичей для тех, кто прошел 90 уровней".
Во второй половине дня немного изменил стиль выбора режима и уровня.
2016-12-21.apk
День 7
И был день, и была но… Ой, ночи у нас пока не было. Самое время это исправить. Когда стили для ночной темы были готовы и кнопка переключения наконец-то переключала тему, а не меняла фон, я столкнулся с небольшой проблемой. Если в игре сменить тему, а потом вернуться назад, то предыдущая активити всё ещё будет с изначальной темой. Поэтому при переходе между активити нужно проверять, совпадает ли тема в настройках с текущей темой в активити.
- public abstract class ThemeableActivity extends AppCompatActivity {
- protected int themeId;
- @Override
- protected void onCreate(Bundle savedInstanceState) {
- themeId = Prefs.with(this).getThemeId();
- setTheme(themeId);
- super.onCreate(savedInstanceState);
- }
- @Override
- protected void onResume() {
- super.onResume();
- if (Prefs.with(this).getThemeId() != ResourceUtils.resolveResourceId(this, R.attr.themeId)) {
- recreate();
- }
- }
- protected boolean isNightTheme() {
- return themeId == R.style.AppTheme_Night;
- }
- protected void toggleTheme() {
- final Prefs prefs = Prefs.with(this);
- final int newThemeId;
- switch (prefs.getThemeId()) {
- case R.style.AppTheme:
- newThemeId = R.style.AppTheme_Night;
- break;
- case R.style.AppTheme_Night:
- default:
- newThemeId = R.style.AppTheme;
- break;
- }
- prefs.setThemeId(newThemeId);
- recreate();
- }
- }
Теперь, наследуясь от класса ThemeableActivity, мы получаем автоматическую поддержку тем.
Также сделал учёт статистики: количество нажатий, число игр, выигрышей и рестартов, число залитых клеток.
- Statistics.with(this)
- .updateClicksFor(index)
- .updateFilledCount(filled);
- // ..
- if (board.isCompleted()) {
- Statistics.with(this)
- .updateGamesPlayedCount()
- .updateWinsCount();
- // ..
- }
2016-12-22.apk
Так прошла неделя. Теперь, имея на руках работающую игру, оставалось только добавлять функционал и придумывать новые идеи.
В следующей части: как подобрать цвета для клеток и не сойти с ума, конечная заставка, история ходов и многое другое.
История создания Mega Flood-It. Часть вторая