Пишем тактическую игру про цифры под Android

    Когда я только взялся за программирование (3 месяца назад), я быстро понял, что лучше сразу начинать заниматься своими проектами. Невозможно с утра до вечера сидеть за книгами или курсами, но если вы начнете делать что-то свое, то запросто просидите за разработкой с утра до утра.

    Эта статья — небольшой туториал по тому, как сделать логическую игру с ботом. Игра будет выглядеть вот так:


    *Подробно опишу правила еще раз в разделе про ИИ.

    Читателей статьи условно разделяю на три группы.
    1. Начали программировать несколько часов назад.
      Вам будет сложно, лучше предварительно пройдите какой-нибудь небольшой курс по введению в Android-разработку, разберитесь с двумерными массивами и интерфейсами. А потом загрузите проект с гитхаба. Комментарии и эта статья помогут вам разобраться, что и как работает.
    2. Уже умеете программировать, но еще не можете назвать себя опытными.
      Вам будет интересно, потому что вы очень быстро сможете сделать свою игру. Я взял на себя грязную работенку по построению логики игры и ui-составляющей, вам же оставляю творческую часть. Вы можете сделать другой режим игры (2 на 2, онлайн и т.п.), изменить алгоритмы бота, создать уровни и т.д.
    3. Опытные.
      Вам может быть интересно подумать над ИИ — написать его не так легко, как кажется на первый взгляд. Так же я был бы очень рад получить от вас замечания по коду — уверен, далеко не все я сделал оптимально.


    Прелюдия


    Сейчас я заново создам проект (чтобы ничего не упустить) и последовательно опишу все шаги. Постараюсь писать код в хорошем тоне, но будут и плохие места, на которые я пошел ради сокращения объема.

    Будем следовать следующему плану:
    • Создадим проект
    • Напишем бота
    • Напишем класс для игры
    • Займемся ui

    Создаем проект


    Все как обычно: создаем новый проект, далее-далее-далее-финиш. Учитывая, что часть аудитории может быть представлена группой «Начали программировать несколько часов назад», приведу подробную инструкцию.

    Инструкция
    Обратите внимания, проект делается в Android Studio.
    Вместо «livermor» в Company Domain укажите что-то свое






    Поменяйте вверху Android на Project. На скрине приведен пример, как и где создавать классы.


    Пишем бота


    Начнем с самой сложной и самой интересной задачи — напишем класс для бота.
    Можете посмотреть видео еще раз и подумать, как бы вы реализовали алгоритмы.
    На всякий случай приведу правила еще раз:
    правила
    соперники ходят по очереди. Один играет за строки, другой за ряды. Выбранное одним игроком число прибавляется к его очкам и определяет ряд(строку) ходов для другого. Ходить в одно и то же место два раза подряд нельзя. Побеждает тот, у кого больше очков на конец игры (когда не осталось возможных ходов).

    Первая идея, которая мне пришла, — просчитать все ходы до конца. Либо до n-го хода. Но как просчитывать ходы? Давайте введем понятие лучшего хода. Наверняка, это такой ход, который максимизирует разницу между вашим ходом и лучшим ходом соперника. То есть вы просчитываете свой лучший ход, основываясь на том, что соперник будет просчитывать ваш лучший ход, ожидая, что вы просчитываете свой лучший ход, основываясь… И так до n. Самый последний ход будет представлять собой просто максимальное число в ряду.

    Как по-вашему, нормальный это алгоритм для бота?

    На самом деле, это даже хуже, чем просто выбирать максимум.
    Вы уже догадались, в чем проблема?

    Дело в том, что мы предполагаем, что соперник будет совершать этот лучший ход. Мы можем выбрать -2, ожидая, что соперник возьмет -3 (его лучший ход, который оправдается в конце партии), но соперник возьмет да и пойдет в +6. Тогда мы все пересчитаем и пойдем в -5, ожидая, что соперник сходит в -4, а он опять возьмет да и выберет +8. И так далее — мы всегда совершаете долгосрочные ходы, и всегда проигрываем здесь и сейчас.

    Самый простой способ сделать этот алгоритм работоспособным — поставить n = 2. То есть предполагать, что соперник просто выберет максимум из своего ряда, и самим искать такой ход, который максимизирует разницу между нашими ходами. К слову, это сделает бота вполне конкурентным.

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

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

    У Бота есть один public метод — сделать ход, к которому мы обращаемся каждый раз, когда хотим получить ход. Соответственно, в этот метод мы передаем все изменяемые значения.

    обращение к тем, кто программирует несколько часов
    То, что я обозвал protected, может быть использовано для наследования — то есть создания детей бота,
    public — для пользования другими классами,
    private — внутренняя кухня, о которой другим классам лучше не знать.

    Если вы практически ничего не поймете — это нормально, я так же проходил первые свои туториалы.
    Класс для Бота — самый сложный, дальше будет легче.

    код бота
    package com.livermor.plusminus; //не забудьте заменить "livermor" на ваш Company Domain
    
    public class Bot {
    
        protected int[][] mMatrix; //digits for buttons
        protected boolean[][] mAllowedMoves; //ходы, куда еще не сходили
        protected int mSize; //размер матрицы
        protected int mPlayerPoints = 0, mAiPoints = 0; //очки игроков
        protected boolean mIsVertical; //играем за строки или ряды
        protected int mCurrentActiveNumb; //номер последнего хода (от 0 до размера матрицы(mSize))
    
        //рейтинги для ходов
        private final static int CANT_GO_THERE = -1000; //если нет хода, то ставим рейтинг -1000
        private final static int WORST_MOVE = -500; // ход, когда мы неизбежно проигрываем
        private final static int VICTORY_MOVE = 500; // ход, когда мы неизбежно выигрываем
        private final static int JACKPOT_INCREASE = 9; //надбавка к рейтингу, если ход принесет куш
        private static final int GOOD_ADVANTAGE = 6;//Куш (джекпот), равный разнице в 6 очков или больше
    
        int depth = 3; //по умолчанию просчитываем на 3 хода вперед
    
        public Bot(
                int[][] matrix,
                boolean vertical
        ) {
            mMatrix = matrix;
            mSize = matrix.length;
            mIsVertical = vertical;
        }
    
        //функция, возвращающая номер хода
        public int move(
                int playerPoints,
                int botPoints,
                boolean[][] moves,
                int activeNumb
        ) {
            mPlayerPoints = playerPoints;
            mAiPoints = botPoints;
            mCurrentActiveNumb = activeNumb;
            mAllowedMoves = moves;
    
            return calcMove();
        }
    
        //можем задать другую глубину просчета
        public void setDepth(int depth) {
            this.depth = depth;
        }
    
        protected int calcMove() {
            //функция для определения лучшего хода игрока
            return calcBestMove(depth, mAllowedMoves,
                    mCurrentActiveNumb, mIsVertical, mAiPoints, mPlayerPoints);
        }
    
        private int calcBestMove(int depth, boolean[][] moves, int lastMove, boolean isVert,
                                 int myPoints, int hisPoints) {
    
            int result = mSize; //возвращаем размер матрицы, если нет доступных ходов
            int[] moveRatings = new int[mSize]; //будем хранить рейтинги ходов в массиве
    
            //если последний ход, возвращаем максимум в ряду (строке)
            if (depth == 1) return findMaxInRow(lastMove, isVert);
            else {
    
                int yMe, xMe; // координаты ходов текущего игрока
                int yHe, xHe; // координаты ходов оппонента
    
                for (int i = 0; i < mSize; i++) {
    
                    //если игрок ходит вертикально, то ходим по строкам (i) в ряду (lastMove)
                    yMe = isVert ? i : lastMove;
                    xMe = isVert ? lastMove : i;
    
                    //если нет хода, ставим ходу минимальный рейтинг
                    if (!mAllowedMoves[yMe][xMe]) {
                        moveRatings[i] = CANT_GO_THERE;
                        continue; //переходим к следующему циклу
                    }
    
                    int myNewP = myPoints + mMatrix[yMe][xMe];//считаем новые очки игрока
                    moves[yMe][xMe] = false;//временно запрещаем ходить туда, куда мы сходили
    
                    //считаем лучший ход для соперника
                    int hisBestMove = calcBestMove(depth - 1, moves, i, !isVert, hisPoints, myPoints);
    
                    //если случилось так, что у соперника нет ходов (т.е. вернулся размер матрицы), то..
                    if (hisBestMove == mSize) {
                        if (myNewP > hisPoints) //если у меня больше очков, то это победный ход
                            moveRatings[i] = VICTORY_MOVE;
                        else //если меньше, то это ужасный ход
                            moveRatings[i] = WORST_MOVE;
    
                        moves[yMe][xMe] = true;//Просчеты завершены, возвращаем ходы как было
                        continue;
                    }
    
                    //теперь определим ход соперника, для того чтобы посчитать разницу между ходами
                    yHe = isVert ? i : hisBestMove;
                    xHe = isVert ? hisBestMove : i;
                    int hisNewP = hisPoints + mMatrix[yHe][xHe];
                    moveRatings[i] = myNewP - hisNewP;
    
                    //и наконец сделаем надбавку к рейтингам ходов в случае, если можно сорвать куш
                    //если глубина уже равна 1, то нет смысла делать рассчеты второй раз
                    if (depth - 1 != 1) {
    
                        //на этот раз нам хватит формулы поиска максимума
                        hisBestMove = findMaxInRow(i, !isVert);
                        yHe = isVert ? i : hisBestMove;
                        xHe = isVert ? hisBestMove : i;
                        hisNewP = hisPoints + mMatrix[yHe][xHe];
    
                        int jackpot = myNewP - hisNewP;//считаем разницу для проверки ситуации куша
                        if (jackpot >= GOOD_ADVANTAGE) { //если куш, то делаем надбавку
                            moveRatings[i] = moveRatings[i] + JACKPOT_INCREASE;
                        }
                    }
    
                    moves[yMe][xMe] = true;//Просчеты завершены, возвращаем ходы как было
                    
                } // рейтинги ходов проставлены, пора выбирать ход с макс. рейтингом
                
                //начинаем с предположения, что максимум — это самый худший вариант (ходов вообще нет)
                int max = CANT_GO_THERE;
                for (int i = 0; i < mSize; i++) {
                    if (moveRatings[i] > max) {
                        max = moveRatings[i];//если есть ход лучше, пусть теперь он будет максимумом
                        result = i;
                    }
                }
            }
    
            //возвращаем ход с максимальным рейтингом
            return result;
        }
    
        //возвращает ход, соответствующий максимальному числу в указанном ряду(строке)
        private int findMaxInRow(int lastM, boolean isVert) {
    
            int currentMax = -10;
            int move = mSize;
    
            int y = 0, x = 0;
            for (int i = 0; i < mSize; i++) {
                y = isVert ? i : lastM;
                x = isVert ? lastM : i;
                int temp = mMatrix[y][x];
                if (mAllowedMoves[y][x] && currentMax <= temp) {
                    currentMax = temp;
                    move = i;
                }
            }
    
            return move;
        }
    }
    
    


    Пишем класс для игры


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

    Игровой класс, назовем его Game, нуждается в двух вещах:
    1. Интерфейс для работы с ui-элементами;
    2. Размер матрицы.

    обращение к тем, кто программирует несколько часов
    Осторожно, в классе Game используются AsyncTask и Handler — либо разберитесь с ними предварительно, либо просто не обращайте на них внимания. Если в двух словах, это классы для работы с потоками. В андроид нельзя изменять элементы интерфейса не из основного потока. Указанные выше классы позволяют решить эту проблему.

    код игры
    package com.livermor.plusminus;
    
    import android.os.AsyncTask;
    import android.os.Handler;
    
    import java.util.Random;
    
    public class Game {
    
        //время задержки перед обновлениями очков, смены анимации
        public static final int mTimeToWait = 800;
        protected MyAnimation mAnimation; //класс AsyncTask для анимации
    
        //матрица цифр и матрица допустимых ходов
        protected int[][] mMatrix; //digits for buttons
        protected volatile boolean[][] mAllowedMoves;
        protected int mSize; //размер матрицы
    
        protected int playerOnePoints = 0, playerTwoPoints = 0;//очки игроков
    
        protected volatile boolean isRow = true; //мы играем за строку или за ряд
        protected volatile int currentActiveNumb; //нужно для определения последнего хода
        protected ResultsCallback mResults;//интерфейс, который будет реализовывать MainActivity
    
        protected volatile Bot bot;//написанный нами бот
        Random rnd; // для заполнения матрицы цифрами и определения первой активной строки
    
        public Game(ResultsCallback results, int size) {
            mResults = results; //передаем сущность интерфейса
            mSize = size;
    
            rnd = new Random();
            generateMatrix(); //заполняем матрицу случайнами цифрами
    
            //условный ход, нужен для определения активной строки
            currentActiveNumb = rnd.nextInt(mSize);
    
            isRow = true; //в нашей версии мы всегда будем играть за строку (просто для упрощения)
    
            for (int yPos = 0; yPos < mSize; yPos++) {
                for (int xPos = 0; xPos < mSize; xPos++) {
    
                    //записываем сгенерированные цифры на кнопки с помощью нашего интерфейса
                    mResults.setButtonText(yPos, xPos, mMatrix[yPos][xPos]);
    
                    if (yPos == currentActiveNumb) // закрашиваем активную строку
                        mResults.changeButtonBg(yPos, xPos, isRow, true);
                }
            }
    
            bot = new Bot(mMatrix, true);
        }
    
        public void startGame() {
            activateRawOrColumn(true);
        }
    
        protected void generateMatrix() {
    
            mMatrix = new int[mSize][mSize];
            mAllowedMoves = new boolean[mSize][mSize];
    
            for (int i = 0; i < mSize; i++) {
                for (int j = 0; j < mSize; j++) {
    
                    mMatrix[i][j] = rnd.nextInt(19) - 9; //от -9 до 9
                    mAllowedMoves[i][j] = true; // сперва все ходы доступны
                }
            }
        }
    
        //будем вызывать метод из MainActivity, которая будет следить за нажатиями кнопок с цифрами
        public void OnUserTouchDigit(int y, int x) {
    
            mResults.onClick(y, x, true);
            activateRawOrColumn(false);//после хода нужно заблокирвоать доступные кнопки
    
            mAllowedMoves[y][x] = false; //два раза в одно место ходить нельзя
            playerOnePoints += mMatrix[y][x]; //берем из матрицы очки
    
            mResults.changeLabel(false, playerOnePoints);//изменяем свои очки
    
            mAnimation = new MyAnimation(y, x, true, isRow);//включаем анимацию смены хода
            mAnimation.execute();
    
            isRow = !isRow; //после хода меняем строку на ряд
            currentActiveNumb = x; //по нашему ходу потом будем определять, куда можно ходить боту
        }
    
        //по завершению анимации разрешаем совершить ход боту
        protected void onAnimationFinished() {
    
            if (!isRow) {//в нашей версии бот играет только за ряды (вертикально)
    
                //используем Handler, потому что предстоит работа с ui, который нельзя обновлять
                //не из главного потока. Handel поставит задачу в очередь главного потока
                Handler handler = new Handler();
                handler.postDelayed(new Runnable() {
                    @Override
                    public void run() {
                        botMove(); //
                    }
                }, mTimeToWait / 2);
    
            } else //если сейчас горизонтальный ход, то активируем строку
                activateRawOrColumn(true);
        }
    
        private void botMove() {
    
            //получаем ход бота
            int botMove = bot.move(playerOnePoints,
                    playerTwoPoints, mAllowedMoves, currentActiveNumb);
    
            if (botMove == mSize) {//если ход равен размеру матрицы, значит ходов нет
                onResult(); //дергаем метод завершения игры
                return; //досрочно выходим из метода
            }
    
            int y = botMove; // по рядам ходит бот
            int x = currentActiveNumb;
            mAllowedMoves[y][x] = false;
            playerTwoPoints += mMatrix[y][x];
            mResults.onClick(y, x, false); //имитируем нажатие на кнопку
            mResults.changeLabel(true, playerTwoPoints); //меняем очки бота
    
            mAnimation = new MyAnimation(y, x, true, isRow); //анимируем смену хода
            mAnimation.execute();
    
            isRow = !isRow; //меняем столбцы на строки
            currentActiveNumb = botMove; //по ходу бота определим, где теперь будет строка
        }
    
        protected void activateRawOrColumn(final boolean active) {
    
            int countMovesAllowed = 0; // для определения, есть ли допустимые ходы
    
            int y, x;
            for (int i = 0; i < mMatrix.length; i++) {
    
                y = isRow ? currentActiveNumb : i;
                x = isRow ? i : currentActiveNumb;
    
                if (mAllowedMoves[y][x]) { //если ход допустим, то
                    mResults.changeButtonClickable(y, x, active); //активируем, либо деактивируем его
                    countMovesAllowed++; //если переменная останется нулем, то ходов нет
                }
            }
            if (active && countMovesAllowed == 0) onResult();
        }
    
        //анимация: кнопки закрашиваются одна за другой
        //сперва закрашиваем новые ходы — затем стираем предыдущие
        protected class MyAnimation extends AsyncTask<Void, Integer, Void> {
    
            int timeToWait = 35; //время задержки в миллисекундах
            int y, x;
            boolean activate;
            boolean row;
    
            protected MyAnimation(int y, int x, boolean activate, boolean row) {
                this.activate = activate;
                this.row = !row;
                this.y = y;
                this.x = x;
            }
    
            @Override
            protected Void doInBackground(Void... params) {
    
                int downInc = row ? x - 1 : y - 1;
                int uppInc = row ? x : y;
    
                if (activate)
                    sleep(Game.mTimeToWait);//наш собственный метод для паузы
    
                if (activate) { //когда активируем ходы, показываем анимацию от точки нажатия к границам
                    while (downInc >= 0 || uppInc < mSize) {
                        //Log.i(TAG, "while in Animation");
    
                        sleep(timeToWait);
                        if (downInc >= 0)
                            publishProgress(downInc--); //метод AsyncTask для отображения прогресса
    
                        sleep(timeToWait);
                        if (uppInc < mSize)
                            publishProgress(uppInc++);
                    }
    
                } else {//когда деактивируем ходы, показываем анимацию от границ к точке нажатия
    
                    int downInc2 = 0;
                    int uppInc2 = mSize - 1;
    
                    while (downInc2 <= downInc || uppInc2 > uppInc) {
    
                        sleep(timeToWait);
                        if (downInc2 <= downInc) publishProgress(downInc2++);
                        sleep(timeToWait);
                        if (uppInc2 > uppInc) publishProgress(uppInc2--);
                    }
                }
    
                return null;
            }
    
            @Override
            protected void onProgressUpdate(Integer... values) {
                int numb = values[0];
    
                int yPos = row ? y : numb;
                int xPos = row ? numb : x;
    
                //вызываем методы интерфеса для изменения фона кнопок с цифрами (ходов)
                if (activate) mResults.changeButtonBg(yPos, xPos, row, activate);
                else mResults.changeButtonBg(yPos, xPos, row, activate);
            }
    
            @Override
            protected void onPostExecute(Void aVoid) {
    
                if (activate) //если только что активировали, то теперь нужно деактивировать старое
                    new MyAnimation(y, x, false, row).execute();
                else //теперь, когда завершили деактивацию, дергаем метод завершения анимации
                    onAnimationFinished();
            }
    
            //наш метод для задержки
            private void sleep(int time) {
                try {
                    Thread.sleep(time);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    
        protected void onResult() {
            //метод интерфеса для отображения результатов
            mResults.onResult(playerOnePoints, playerTwoPoints);
        }
    
        //Интерфейс для MainActivity, который будет изменять ui элементы
        //*********************************************************************************
        public interface ResultsCallback {
    
            //для изменения ваших очков и очков соперника
            void changeLabel(boolean upLabel, int points);
    
            //для изменения цвета кнопок
            void changeButtonBg(int y, int x, boolean row, boolean active);
    
            //для заполнения кнопок цифрами
            void setButtonText(int y, int x, int text);
    
            //для блокировки/разблокировки кнопок
            void changeButtonClickable(int y, int x, boolean clickable);
    
            //по окончанию партии
            void onResult(int one, int two);
    
            //по нажатию на кнопку
            void onClick(int y, int x, boolean flyDown);
        }
    }
    


    Работаем над пользовательским интерфейсом


    Осталась заключительная часть — связать логику игры с пользовательским интерфейсом. Здесь будет меньше комментариев и пояснений, мы просто сделаем шаг за шагом все необходимые вещи.
    Пометка для тех, кто программирует несколько часов
    Убедитесь, что вверху у вас стоит Project, а не Android.
    На данный момент у нас есть 3 класса: созданные нами Bot и Game и уже существующий класс MainActivity. Сейчас нам предстоит изменить несколько xml-документов (обведенных красным), создать еще один класс для цифр-кнопок и создать drawable-элемент (показываю черной стрелкой, как это делается).


    1. Запрещаем экрану поворачиваться:

    В AndroidManifest добавляем под MainActivity — android:screenOrientation=«portrait»
    Делаем для того, чтобы запретить переворачивать экран (для упрощения туториала).
    AndroidManifest.xml
    <?xml version="1.0" encoding="utf-8"?>
    <!--
    если будете копировать, то не забудьте поменять package на свой.
    вообще, конечно, лучше просто копируйте одну строчку
    >>> android:screenOrientation="portrait"
    -->
    <manifest package="com.livermor.plusminus"
              xmlns:android="http://schemas.android.com/apk/res/android">
    
        <application
            android:allowBackup="true"
            android:icon="@mipmap/ic_launcher"
            android:label="@string/app_name"
            android:supportsRtl="true"
            android:theme="@style/AppTheme">
            <activity android:name=".MainActivity"
                      android:screenOrientation="portrait">
                <intent-filter>
                    <action android:name="android.intent.action.MAIN"/>
    
                    <category android:name="android.intent.category.LAUNCHER"/>
                </intent-filter>
            </activity>
        </application>
    
    </manifest>
    


    2. Добавляем нужные нам цвета:

    Заходим в colors.xml, удаляем имеющиеся цвета, добавляем эти:
    colors.xml
    <?xml version="1.0" encoding="utf-8"?>
    <resources>
        <color name="colorPrimary"      >#7C7B7B</color>
        <color name="colorPrimaryDark"  >#424242</color>
        <color name="colorAccent"       >#FF4081</color>
        <color name="bgGrey"            >#C4C4C4</color>
        <color name="bgRed"             >#FC5C70</color>
        <color name="bgBlue"            >#4A90E2</color>
        <color name="black"             >#000</color>
        <color name="lightGreyBg"       >#DFDFDF</color>
        <color name="white"             >#fff</color>
    </resources>
    


    3. Меняем тему приложения:

    В styles.xml заменяем Theme.AppCompat.Light.DarkActionBar на Theme.AppCompat.Light.NoActionBar:
    styles.xml
    <resources>
        <style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
            <item name="colorPrimary">@color/colorPrimary</item>
            <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
            <item name="colorAccent">@color/colorAccent</item>
        </style>
    </resources>
    


    4. Устанавливаем размеры:

    Заменим размеры в dimens.xml на следующие::
    dimens.xml
    <resources>
        <dimen name="button.radius">10dp</dimen>
        <dimen name="sides">10dp</dimen>
        <dimen name="up_bottom">20dp</dimen>
        <dimen name="label_height">55dp</dimen>
        <dimen name="label_text_size">40dp</dimen>
        <dimen name="label_padding_sides">6dp</dimen>
    </resources>
    


    5. Создаем фоны для кнопок:

    Нужно создать три xml в папке drawable:
    bg_blue.xml
    <?xml version="1.0" encoding="utf-8"?>
    <shape xmlns:android="http://schemas.android.com/apk/res/android">
        <solid android:color="@color/bgBlue"/>
    
        <corners android:bottomRightRadius="@dimen/button_radius"
                 android:bottomLeftRadius="@dimen/button_radius"
                 android:topLeftRadius="@dimen/button_radius"
                 android:topRightRadius="@dimen/button_radius"/>
    </shape>
    


    bg_red.xml
    <?xml version="1.0" encoding="utf-8"?>
    <shape xmlns:android="http://schemas.android.com/apk/res/android">
        <solid android:color="@color/bgRed"/>
    
        <corners android:bottomRightRadius="@dimen/button_radius"
                 android:bottomLeftRadius="@dimen/button_radius"
                 android:topLeftRadius="@dimen/button_radius"
                 android:topRightRadius="@dimen/button_radius"/>
    </shape>
    


    bg_grey.xml
    <?xml version="1.0" encoding="utf-8"?>
    <shape xmlns:android="http://schemas.android.com/apk/res/android">
        <solid android:color="@color/bgGrey"/>
    
        <corners android:bottomRightRadius="@dimen/button_radius"
                 android:bottomLeftRadius="@dimen/button_radius"
                 android:topLeftRadius="@dimen/button_radius"
                 android:topRightRadius="@dimen/button_radius"/>
    </shape>
    


    6. Изменяем макет экрана:

    Для матрицы я буду использовать GridLayout — возможно, не самое лучшее решение, но оно показалось мне довольно простым и коротким.

    Просто замените имеющийся код на мой — там пустой GridLayout (заполним его кодом в MainActivity) и два TextView-элемента для показателей очков игроков (RelativeLayout внутри другого RelativeLayout — для того, чтобы выравнять все по центру по вертикали. View «center» — для выравнивания показателей очков к центру по горизонтали).

    Да, и не беспокойтесь, в preview вы ничего не увидите, кроме верхней надписи Бот, так и должно быть.
    activity_main.xml
    <?xml version="1.0" encoding="utf-8"?>
    <RelativeLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="#000"
        tools:context="com.livermor.myapplication.MainActivity">
    
        <RelativeLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_centerVertical="true"
            android:background="@color/lightGreyBg">
    
            <View
                android:id="@+id/center"
                android:layout_width="10dp"
                android:layout_height="1dp"
                android:layout_centerInParent="true"/>
    
            <TextView
                android:id="@+id/upper_scoreboard"
                android:background="@drawable/bg_red"
                android:layout_width="match_parent"
                android:layout_height="55dp"
                android:layout_alignParentLeft="true"
                android:layout_alignParentTop="true"
                android:layout_marginLeft="@dimen/sides"
                android:layout_marginTop="15dp"
                android:layout_toLeftOf="@id/center"
                android:gravity="center_vertical|center_horizontal"
                android:paddingLeft="@dimen/label_padding_sides"
                android:paddingRight="@dimen/label_padding_sides"
                android:text="Бот: 0"
                android:textColor="@color/white"
                android:textSize="@dimen/label_text_size"/>
    
            <GridLayout
                xmlns:android="http://schemas.android.com/apk/res/android"
                android:id="@+id/my_grid"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:layout_below="@+id/upper_scoreboard"
                android:layout_gravity="center"
                android:foregroundGravity="center"
                android:layout_marginLeft="@dimen/sides"
                android:layout_marginRight="@dimen/sides"
                android:layout_marginBottom="@dimen/up_bottom"
                android:layout_marginTop="@dimen/up_bottom"/>
    
            <TextView
                android:id="@+id/lower_scoreboard"
                android:background="@drawable/bg_blue"
                android:layout_width="match_parent"
                android:layout_height="@dimen/label_height"
                android:layout_alignParentRight="true"
                android:layout_alignParentEnd="true"
                android:layout_below="@+id/my_grid"
                android:layout_marginBottom="15dp"
                android:layout_marginRight="15dp"
                android:layout_toRightOf="@id/center"
                android:gravity="center_vertical|center_horizontal"
                android:paddingLeft="@dimen/label_padding_sides"
                android:paddingRight="@dimen/label_padding_sides"
                android:text="Вы: 0"
                android:textColor="@color/white"
                android:textSize="@dimen/label_text_size"/>
    
        </RelativeLayout>
    
    </RelativeLayout>
    


    7. Создаем класс MyButton, наследующий Button:

    Создаем свой класс для кнопок, чтобы удобнее было получать координаты каждой кнопки в матрице.
    Код
    package com.livermor.plusminus;
    
    import android.content.Context;
    import android.util.AttributeSet;
    import android.widget.Button;
    
    public class MyButton extends Button {
        
        private MyOnClickListener mClickListener;//наш интерфейс учета кликов для MainActivity
        int idX = 0;
        int idY = 0;
    
        //конструктор, в котором будем задавать координаты кнопки в матрице
        public MyButton(Context context, int x, int y) {
            super(context);
            idX = x;
            idY = y;
        }
    
        public MyButton(Context context) {
            super(context);
        }
    
        public MyButton(Context context, AttributeSet attrs) {
            super(context, attrs);
        }
    
        public MyButton(Context context, AttributeSet attrs, int defStyleAttr) {
            super(context, attrs, defStyleAttr);
        }
    
        @Override //метод View для отлавливания кликов
        public boolean performClick() {
            super.performClick();
    
            mClickListener.OnTouchDigit(this);//будем дергать метод интерфейса
            return true;
        }
    
        public void setOnClickListener(MyOnClickListener listener){
            mClickListener = listener;
        }
    
        public int getIdX(){
            return idX;
        }
    
        public int getIdY(){
            return idY;
        }
    
        //Интерфейс для MainActivity
        //************************************
        public interface MyOnClickListener {
    
            void OnTouchDigit(MyButton v);
        }
    }
    


    8. И, наконец, отредактируем класс MainActivity:

    Код
    package com.livermor.plusminus;
    
    import android.graphics.Typeface;
    import android.os.Handler;
    import android.support.v4.content.ContextCompat;
    import android.support.v7.app.AppCompatActivity;
    import android.os.Bundle;
    import android.view.ViewGroup;
    import android.view.ViewTreeObserver;
    import android.view.animation.AlphaAnimation;
    import android.view.animation.AnimationSet;
    import android.view.animation.TranslateAnimation;
    import android.widget.Button;
    import android.widget.GridLayout;
    import android.widget.TextView;
    import android.widget.Toast;
    
    public class MainActivity extends AppCompatActivity
            implements Game.ResultsCallback, MyButton.MyOnClickListener {
    
        private static final int MATRIX_SIZE = 5;// можете ставить от 2 до 20))
    
        //ui
        private TextView mUpText, mLowText;
        GridLayout mGridLayout;
        private MyButton[][] mButtons;
    
        private Game game;
    
        @Override
        protected void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            setContentView(R.layout.activity_main);
    
            mGridLayout = (GridLayout) findViewById(R.id.my_grid);
            mGridLayout.setColumnCount(MATRIX_SIZE);
            mGridLayout.setRowCount(MATRIX_SIZE);
            mButtons = new MyButton[MATRIX_SIZE][MATRIX_SIZE];//5 строк и 5 рядов
    
            //создаем кнопки для цифр
            for (int yPos = 0; yPos < MATRIX_SIZE; yPos++) {
                for (int xPos = 0; xPos < MATRIX_SIZE; xPos++) {
                    MyButton mBut = new MyButton(this, xPos, yPos);
    
                    mBut.setTextSize(30-MATRIX_SIZE);
                    Typeface boldTypeface = Typeface.defaultFromStyle(Typeface.BOLD);
                    mBut.setTypeface(boldTypeface);
                    mBut.setTextColor(ContextCompat.getColor(this, R.color.white));
                    mBut.setOnClickListener(this);
                    mBut.setPadding(1, 1, 1, 1); //так цифры будут адаптироваться под размер
    
                    mBut.setAlpha(1);
                    mBut.setClickable(false);
    
                    mBut.setBackgroundResource(R.drawable.bg_grey);
    
                    mButtons[yPos][xPos] = mBut;
                    mGridLayout.addView(mBut);
                }
            }
            
            mUpText = (TextView) findViewById(R.id.upper_scoreboard);
            mLowText = (TextView) findViewById(R.id.lower_scoreboard);
    
            //расположим кнопки с цифрами равномерно внутри mGridLayout
            mGridLayout.getViewTreeObserver().addOnGlobalLayoutListener(
                    new ViewTreeObserver.OnGlobalLayoutListener() {
                        @Override
                        public void onGlobalLayout() {
                            setButtonsSize();
                            //нам больше не понадобится OnGlobalLayoutListener
                            mGridLayout.getViewTreeObserver().removeOnGlobalLayoutListener(this);
                        }
                    });
    
            game = new Game(this, MATRIX_SIZE); //создаем класс игры
            game.startGame(); //и запускаем ее
    
        }//onCreate
    
        private void setButtonsSize() {
            int pLength;
            final int MARGIN = 6;
    
            int pWidth = mGridLayout.getWidth();
            int pHeight = mGridLayout.getHeight();
            int numOfCol = MATRIX_SIZE;
            int numOfRow = MATRIX_SIZE;
    
            //сделаем mGridLayout квадратом
            if (pWidth >= pHeight) pLength = pHeight;
            else pLength = pWidth;
            ViewGroup.LayoutParams pParams = mGridLayout.getLayoutParams();
            pParams.width = pLength;
            pParams.height = pLength;
            mGridLayout.setLayoutParams(pParams);
    
            int w = pLength / numOfCol;
            int h = pLength / numOfRow;
    
            for (int yPos = 0; yPos < MATRIX_SIZE; yPos++) {
                for (int xPos = 0; xPos < MATRIX_SIZE; xPos++) {
                    GridLayout.LayoutParams params = (GridLayout.LayoutParams)
                            mButtons[yPos][xPos].getLayoutParams();
                    params.width = w - 2 * MARGIN;
                    params.height = h - 2 * MARGIN;
                    params.setMargins(MARGIN, MARGIN, MARGIN, MARGIN);
                    mButtons[yPos][xPos].setLayoutParams(params);
                    //Log.w(TAG, "process goes in customizeMatrixSize");
                }
            }
        }
    
        //MyButton.MyOnClickListener интерфейс
        //*************************************************************************
        @Override
        public void OnTouchDigit(MyButton v) {
            game.OnUserTouchDigit(v.getIdY(), v.getIdX());
        }
    
        //Game.ResultsCallback интерфейс
        //*************************************************************************
        @Override
        public void changeLabel(boolean upLabel, int points) {
            if (upLabel) mUpText.setText(String.format("Бот: %d", points));
            else mLowText.setText(String.valueOf(String.format("Вы: %d", points)));
        }
    
        @Override
        public void changeButtonBg(int y, int x, boolean row, boolean active) {
    
            if (active) {
                if (row) mButtons[y][x].setBackgroundResource(R.drawable.bg_blue);
                else mButtons[y][x].setBackgroundResource(R.drawable.bg_red);
    
            } else {
                mButtons[y][x].setBackgroundResource(R.drawable.bg_grey);
            }
        }
    
        @Override
        public void setButtonText(int y, int x, int text) {
            mButtons[y][x].setText(String.valueOf(text));
        }
    
        @Override
        public void changeButtonClickable(int y, int x, boolean clickable) {
            mButtons[y][x].setClickable(clickable);
        }
    
        @Override
        public void onResult(int playerOnePoints, int playerTwoPoints) {
    
            String text;
            if (playerOnePoints > playerTwoPoints) text = "вы победили";
            else if (playerOnePoints < playerTwoPoints) text = "бот победил";
            else text = "ничья";
    
            Toast.makeText(this, text, Toast.LENGTH_SHORT).show();
    
            //через 1500 миллисекунд выполним метод run
            new Handler().postDelayed(new Runnable() {
                @Override
                public void run() {
                    recreate(); //начать новую игру — пересоздать класс MainActivity
                }
            }, 1500);
        }
    
        @Override
        public void onClick(final int y, final int x, final boolean flyDown) {
    
            final Button currentBut = mButtons[y][x];
    
            currentBut.setAlpha(0.7f);
            currentBut.setClickable(false);
    
            AnimationSet sets = new AnimationSet(false);
            int direction = flyDown ? 400 : -400;
            TranslateAnimation animTr = new TranslateAnimation(0, 0, 0, direction);
            animTr.setDuration(810);
            AlphaAnimation animAl = new AlphaAnimation(0.4f, 0f);
            animAl.setDuration(810);
            sets.addAnimation(animTr);
            sets.addAnimation(animAl);
            currentBut.startAnimation(sets);
    
            new Handler().postDelayed(new Runnable() {
                @Override
                public void run() {
    
                    currentBut.clearAnimation();
                    currentBut.setAlpha(0);
                }
            }, 800);
        }
    }
    


    Финиш


    Можете запускать проект. Если что-то пойдет не так, пишите в комментариях или в личку. На всякий случай, еще раз даю ссылку на гитхаб. Буду рад услышать идеи по боту и замечания по коду.
    • +10
    • 20,8k
    • 8
    Поделиться публикацией
    Похожие публикации
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама
    Комментарии 8
    • 0
      Хм, писал такую же игру под Windows, использовал минимаксный алгоритм.
      С регулируемой глубиной-сложностью.
      Правда поле у меня было 8х8. При глубине поиска в 4 хода выиграть у бота уже практически невозможно, только если повезет. Но и задлумываться он начинает уже на несколько минут.
      Вечером поищу приложение.
      • 0
        Залил сюда: rghost.ru/7bhjV8tXx
        Немного запамятовал, все-таки на 4 уровне сложности думает быстро, а 5й был выпилен как раз из-за долгих ходов
        Приложение было написано аж в 11 году, просто для интереса, как раз для разработки ИИ к игре
    • 0
      Интересная игра. Планируется ли пвп режим?
      • –1
        Да, собираюсь онлайн-режим добавить попозже.
        На одном девайсе уже есть.
        • –1
          Если не хотите открывать уровни по IQ, то там можно на замок Профессора нажать, и он откроется:
          Заголовок
          Содержимое
          image
        • 0
          а планируете выложить apk?
          • –1
            Можно с Google play загрузить — там такая же версия, как на видео. Либо с 4pda скачать apk. Там, правда, грозятся, что закроют тему.

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