Pull to refresh

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

Reading time 21 min
Views 24K
Когда я только взялся за программирование (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);
    }
}


Финиш


Можете запускать проект. Если что-то пойдет не так, пишите в комментариях или в личку. На всякий случай, еще раз даю ссылку на гитхаб. Буду рад услышать идеи по боту и замечания по коду.
Tags:
Hubs:
+10
Comments 8
Comments Comments 8

Articles