Пишем игру для Android используя Canvas

в 17:09, , рубрики: android development, canvas, Разработка под android, метки: ,

Привет!
Сегодня я хочу рассказать о том, как написать простую логическую игру для Android OS с использованием Canvas. С этой игрой я познакомился около 5 лет назад на своем телефоне. Название забылось, а поиски на нескольких тематических форумах ни к чему не привели и я решил написать свою реализации этой игры. Разработка для меня скорее хобби, хотя иногда берусь за небольшие проекты. После некоторых раздумий я решил не использовать движок, а писать самостоятельно. Причины такого решения: желание получить опыт работы с Canvas'ом.

Суть такова...

Имеется игровое поле подобное шахматной доске, однако без деления клеток на черные и белые. Размер поля может быть произвольным, однако играть на поле меньше, чем 2х3 не имеет особого смысла. Мне нравится играть на поле 10х10.

Количество игроков может произвольным, однако на мой взгляд интереснее играть вдвоем или вчетвером. Если играть втроем на прямоугольном поле, то возникает дисбаланс, поскольку один из игроков будет отдален от «пустого» угла. Если играть компанией больше, чем 4 человека, то сложно воплощать свою стратегию.

Каждый игрок по очереди должен положить один объект (назовем его атомом) в свободную ячейку или в ячейку, где уже есть его атомы. Если в ячейке накапливается «критическая масса», равная числу соседних ячеек, то атомы из данной ячейки перемещаются в соседние ячейки, при этом находящиеся в соседних ячейках атомы «захватываются», т.е. теперь они принадлежат игроку, чьи атомы разлетелись.

Пара картинок для пояснения сути.

Пустое поле 4х4 с указанием критической массы для каждой ячейки.
Пишем игру для Android используя Canvas

Игровая ситуация после третьего хода.
Пишем игру для Android используя Canvas

Игровая ситуация после четвертого хода (первыми ходят синие). Видно, что в верхнем левом углу скопилось критическое количество атомов.
Пишем игру для Android используя Canvas

Бах! Они разлетелись, захватив 2 синих атома в ячейке [0][1]. И в этой ячейке [0][1] теперь тоже критическое количество. Цепная реакция!
Пишем игру для Android используя Canvas

Ситуация после разлета. Конец четвертого хода. Синие сейчас совершат пятый ход.
Пишем игру для Android используя Canvas

Реализация. Графическая часть.

Приступим к реализации. Создадим производный от View класс.

public class GameView extends View {
 
    private Bitmap mBitmap;
    private Canvas mCanvas;
    private Paint mPaint, mBitmapPaint;
    private float canvasSize;
    private boolean isLock=false;
private final int horizontalCountOfCells, verticalCountOfCells;
 
    public GameView(Context context, AttributeSet attrs) {
        super(context, attrs);
        horizontalCountOfCells =10;
        verticalCountOfCells =10;
        canvasSize=(int)convertDpToPixel(300, context);
 
        mBitmap = Bitmap.createBitmap((int) canvasSize, (int) canvasSize, Bitmap.Config.ARGB_8888);
        mCanvas = new Canvas(mBitmap);
        mBitmapPaint = new Paint(Paint.DITHER_FLAG);
        mPaint=new Paint();
        mPaint.setAntiAlias(true);
        mPaint.setDither(true);
        mPaint.setColor(0xffff0505);
        mPaint.setStrokeWidth(5f);
        mPaint.setStyle(Paint.Style.STROKE);
        mPaint.setStrokeJoin(Paint.Join.ROUND);
        mPaint.setStrokeCap(Paint.Cap.ROUND);
        //рисуем разделительные линии
        for(int x=0;x< horizontalCountOfCells +1;x++)
        mCanvas.drawLine((float)x* canvasSize / horizontalCountOfCells, 0(float)x* canvasSize / horizontalCountOfCells, canvasSize, mPaint);
        for(int y=0;y< verticalCountOfCells +1;y++)
        mCanvas.drawLine(0(float)y* canvasSize / verticalCountOfCells, canvasSize, (float)y* canvasSize / verticalCountOfCells, mPaint);
    }
 
 
    Override
    protected void onDraw(Canvas canvas) {
        canvas.drawBitmap(mBitmap, 00, mBitmapPaint);
    }
 
    public static float convertDpToPixel(float dp,Context context){
        Resources resources = context.getResources();
        DisplayMetrics metrics = resources.getDisplayMetrics();
        return dp * (metrics.densityDpi/160f);
    }
}

Здесь я допустил немного говнокода кода плохого качество, а именно захардкордил в коде цвет линий, разделяющих ячейки игрового поля и размер вьюхи считается равным 300 dp. Этот размер можно получить из объекта attrs класса AttributeSet, однако не будем загромождать код.

Также сразу набросаем Activity, дабы убедиться, что все рисуется красиво.

public class GameActivity extends Activity {
 
    Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);
    }
}

И разметка main.xml

<LinearLayout xmlns:android=«schemas.android.com/apk/res/android»
        android:orientation=«vertical»
        android:layout_width=«fill_parent»
        android:layout_height=«fill_parent»
        android:background="#fff"
        android:gravity=«center_horizontal»>
<com.devindi.chain.GameView
        android:layout_width=«300dp»
        android:layout_height=«300dp»
        android:id="@+id/game_view"
        android:layout_gravity=«center»
        android:background="#000"/>
</LinearLayout>

Теперь добавим возможность изменять масштаб игрового поля, поскольку возможны промахи мимо нужной ячейки по причине их малого размера. Для этого переопределяем нужные нам методы реализации ScaleGestureDetector.SimpleOnScaleGestureListener интерфейса OnScaleGestureListener.

public class GameView extends View {
 
    private Bitmap mBitmap;
    private Canvas mCanvas;
    private Paint mPaint, mBitmapPaint;
    private float canvasSize;
    private final ScaleGestureDetector scaleGestureDetector;
    private final int viewSize;
    private float mScaleFactor;
    private boolean isLock=false;
private final int horizontalCountOfCells, verticalCountOfCells;
 
    public GameView(Context context, AttributeSet attrs) {
        super(context, attrs);

        scaleGestureDetector=new ScaleGestureDetector(context, new MyScaleGestureListener());
        viewSize=(int)convertDpToPixel(300, context);
        mScaleFactor=1f;
        canvasSize=viewSize;
    }
 
    Override
    public boolean onTouchEvent(MotionEvent event) {
            scaleGestureDetector.onTouchEvent(event);
        return true;
    }
 
    Override
    protected void onDraw(Canvas canvas) {
        canvas.save();
        canvas.scale(mScaleFactor, mScaleFactor);
        canvas.drawBitmap(mBitmap, 00, mBitmapPaint);
        canvas.restore();
    }
 
    private class MyScaleGestureListener extends ScaleGestureDetector.SimpleOnScaleGestureListener {
        Override
        public boolean onScale(ScaleGestureDetector scaleGestureDetector) {
            float scaleFactor=scaleGestureDetector.getScaleFactor();
            float focusX=scaleGestureDetector.getFocusX();
            float focusY=scaleGestureDetector.getFocusY();
            if(mScaleFactor*scaleFactor>1 && mScaleFactor*scaleFactor<2){
                mScaleFactor *= scaleGestureDetector.getScaleFactor();
                canvasSize =viewSize*mScaleFactor;
                int scrollX=(int)((getScrollX()+focusX)*scaleFactor-focusX);
                scrollX=Math.min( Math.max(scrollX, 0)(int) canvasSize -viewSize);
                int scrollY=(int)((getScrollY()+focusY)*scaleFactor-focusY);
                scrollY=Math.min( Math.max(scrollY, 0)(int) canvasSize -viewSize);
                scrollTo(scrollX, scrollY);
            }
            invalidate();
            return true;
        }
    }
}

Обратите внимание, что установлены границы значения увеличинения (увеличение будет на промежутке от 1 до 2), а также производится скролл игрового поля к точке зумирования (точка посередине между пальцами) с проверкой на показ области вне игрового поля. Скролл к точке зуммирования (фокальной точке) производится следующим образом — расчитывается координаты фокальной точки относительно начала канваса(верхний левый угол), умножаются на коэффициент зуммирования и вычитаются координаты точки относительно вьюхи. После этого берем ближайшее значение из промежутка [0, canvasSize -viewSize] для предотвращения скролла за пределы игрового поля.

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

public class GameView extends View {
    …
    private final GestureDetector detector;
 
    public GameView(Context context, AttributeSet attrs) {
        super(context, attrs);
        …
        detector=new GestureDetector(context, new MyGestureListener());
 
}
 
    Override
    public boolean onTouchEvent(MotionEvent event) {
        if(event.getPointerCount()==1){
            detector.onTouchEvent(event);
        }else if(event.getPointerCount()==2)
            scaleGestureDetector.onTouchEvent(event);
        return true;
    }
 
private class MyGestureListener extends GestureDetector.SimpleOnGestureListener
    {
        Override
        public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY)
        {
            if(getScrollX()+distanceX< canvasSize -viewSize && getScrollX()+distanceX>0){
                scrollBy((int)distanceX, 0);
            }
            if(getScrollY()+distanceY< canvasSize -viewSize && getScrollY()+distanceY>0){
                scrollBy(0(int)distanceY);
            }
            return true;
        }
 
        Override
        public boolean onSingleTapConfirmed(MotionEvent event){
            if(!isLock){
                int cellX=(int)(event.getX()+getScrollX())/mScaleFactor;
                int cellY=(int)(event.getY()+getScrollY())/mScaleFactor;
//
            }
            return true;
        }
 
        Override
        public boolean onDoubleTapEvent(MotionEvent event){
            mScaleFactor=1f;
            canvasSize =viewSize;
            scrollTo(00);
            invalidate();
            return true;
        }
    }
 
    void lock(){
        isLock=true;
    }
 
    void unlock(){
        isLock=false;
    }
}

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

Напишем рисования атомов drawAtoms. параметры метода — координаты ячейки, в которой рисуем атомы, «хозяин» атомов и количество атомов.

 void drawAtoms(int cellX, int cellY, int player, int count){
        float x0=((1f/(2* horizontalCountOfCells))*viewSize+(1f/ horizontalCountOfCells)*cellX*viewSize);
        float y0=((1f/(2* verticalCountOfCells))*viewSize+(1f/ verticalCountOfCells)*cellY*viewSize);
        mPaint.setColor(colors[player-1]);
        switch (count){
            //todo non-absolute values
            case 1:
                drawAtoms(cellX, cellY, player, 0);
                mCanvas.drawCircle(x0, y0, 3, mPaint);
                break;
            case 2:
                drawAtoms(cellX, cellY, player, 0);
                mCanvas.drawCircle(x0-7, y0, 3, mPaint);
                mCanvas.drawCircle(x0+7, y0, 3, mPaint);
                break;
            case 3:
                drawAtoms(cellX, cellY, player, 0);
                mCanvas.drawCircle(x0 - 7, y0 + 43, mPaint);
                mCanvas.drawCircle(x0 + 7, y0 + 43, mPaint);
                mCanvas.drawCircle(x0, y0-83, mPaint);
                break;
            case 4:
                drawAtoms(cellX, cellY, player, 0);
                mCanvas.drawCircle(x0-7, y0-73, mPaint);
                mCanvas.drawCircle(x0-7, y0+73, mPaint);
                mCanvas.drawCircle(x0+7, y0+73, mPaint);
                mCanvas.drawCircle(x0+7, y0-73, mPaint);
                break;
            case 0:
                mPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
                mPaint.setStyle(Paint.Style.FILL);
                mCanvas.drawCircle(x0, y0, 17, mPaint);
                mPaint.setStyle(Paint.Style.STROKE);
                mPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC));
                break;
        }
        invalidate();
    }

На данный момент метод имеет недоработку в виде абсолютных значений размеров атомов и расстояний между ними. К чему это может привести? При размере поля меньше, чем 10х10 атомы будут смотреться мелкими, а при большем размере поля могут не влезть в ячейку.

Осталось добавить скроллбары. Этот процесс хорошо описан здесь habrahabr.ru/post/120931/. И получить объект класса, описывающего игровую логику, также не забудьте дополнить метод обработки одиночного тапа строкой logic.addAtom((int)(horizontalCountOfCells *eventX/viewSize), (int)(verticalCountOfCells *eventY/viewSize), true);

Итоговый код класса можно посмотреть на гитхабе

Реализация. Игровая логика.

Создадим класс GameLogic, описывающий логику игры. Также нам понадобится внутренний класс Cell, хранящий параметры ячейки игрового поля.

public class GameLogic {
private class Cell{
     int player=0, countOfAtoms=0;
     final int maxCountOfAtoms;
 
     Cell(int maxCountOfAtoms){
         this.maxCountOfAtoms=maxCountOfAtoms;
     }
 
     public int getCountOfAtoms() {
         return countOfAtoms;
     }
 
     public int getPlayer() {
         return player;
     }
 
     public void setPlayer(int player) {
         this.player = player;
     }
 
     public void resetCount() {
         this.countOfAtoms = 0;
     }
 
     public void addAtom(){
         this.countOfAtoms++;
     }
 
     boolean isFilled(){
         return this.countOfAtoms == this.maxCountOfAtoms;
     }
 }
}

Сам класс GameLogic

private final GameView view;
    private final GameActivity activity;
    private int moveNumber=0, currentPlayer=1;
    private final int COUNT_OF_PLAYERS, BOARD_WIDTH, BOARD_HEIGHT;
    private final Cell[][] cells;
 
 
    public GameLogic(GameView view, GameActivity activity) {
        this.view = view;
        this.activity=activity;
        //init game params (size of board, count of players, players colors
        this.COUNT_OF_PLAYERS=2;
        this.BOARD_HEIGHT=10;
        this.BOARD_WIDTH=10;
        cells=new Cell[BOARD_WIDTH][BOARD_HEIGHT];
        for(int x=0; x<BOARD_WIDTH; x++){
            for(int y=0; y<BOARD_HEIGHT; y++){
                if((x==0 || x==BOARD_WIDTH-1) && (y==0 || y==BOARD_HEIGHT-1)){
                    cells[x][y]=new Cell(2);
                }else
                if((x==0 || x==BOARD_WIDTH-1) || (y==0 || y==BOARD_HEIGHT-1)){
                    cells[x][y]=new Cell(3);
                }else{
                    cells[x][y]=new Cell(4);
                }
            }
        }
    }
 
    void addAtom(final int cellX, final int cellY, boolean isClick){
        final Cell currentCell;
        try{
            currentCell=cells[cellX][cellY];
        }catch (Exception ex){
            return;
        }
        if(currentCell.getPlayer()==currentPlayer){
            currentCell.addAtom();
            view.drawAtoms(cellX, cellY, currentPlayer, currentCell.getCountOfAtoms());
            if(currentCell.isFilled()){
                view.lock();
                final List<Cell> nearby=new ArrayList<Cell>(4);
                selfAddCell(cellX, cellY-1, nearby);
                selfAddCell(cellX, cellY+1, nearby);
                selfAddCell(cellX-1, cellY, nearby);
                selfAddCell(cellX+1, cellY, nearby);
                for(Cell nearbyCell:nearby){
                    nearbyCell.setPlayer(currentPlayer);
                }
                currentCell.setPlayer(0);
                Handler handler=new Handler();
                handler.postDelayed(new Runnable() {
                    Override
                    public void run() {
                        currentCell.resetCount();
                        view.drawAtoms(cellX, cellY, currentPlayer, 0);
                        view.unlock();
                        addAtom(cellX, cellY-1false);
                        addAtom(cellX, cellY+1false);
                        addAtom(cellX-1, cellY, false);
                        addAtom(cellX+1, cellY, false);
                        for(Cell nearbyCell:nearby){
                            if(nearbyCell.isFilled()){
                                return;
                            }
                        }
                        endTurn(true);
                    }
                }1000);
                return;
            }
        }else if(currentCell.getPlayer()==0){
            currentCell.addAtom();
            view.drawAtoms(cellX, cellY, currentPlayer, currentCell.getCountOfAtoms());
            currentCell.setPlayer(currentPlayer);
        }else{
            return;
        }
        endTurn(isClick);
    }
 
    private void endTurn(boolean isClick){
        if(currentPlayer==COUNT_OF_PLAYERS && isClick){
            moveNumber++;
            currentPlayer=1;
        }else if(isClick){
            currentPlayer++;
        }
        activity.setPlayerName(currentPlayer);
    }
 
    private void selfAddCell(int cellX, int cellY, List<Cell> target){
        try{
            target.add(cells[cellX][cellY]);
        }catch (IndexOutOfBoundsException ignore){}
    }

Здесь мы видим конструктор, который создает двумерный массив объектов Cell и метод addAtom, вызываемый из вьюхи по одиночному тапу и из себя при условии что ячейка наполнена атомами под завязку.

После сборки обработать напильником

Далее требуется небольшое допиливание получившегося приложения в виде создания Activity для настройки игры (размер игрового поля, количество игроков, цвет атомов и имена игроков, однако это не тема данной статьи. К сожалению написанный мной класс описания игровой логики имеет серьезные недочеты — в некоторых случая неверный подсчет очков и неверное определение победителя при количестве игроков больше 2х. Итоговый код можно посмотреть на гитхабе, получившуюся игру попробовать на Google Play

Надеюсь, кому-нибудь статья поможет.
Прошу большими камнями не кидаться. Осознаю низкое качество кода и обещаю стремиться избегать говнокода. Спасибо за внимание.

Автор: xotta6bl4

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js