Пишем игру для Android. Часть 1. Surface

Пишем игру для Android. Часть 1. Surface

27.03.2009

Источник: megadarja.blogspot.com

На прошлую статью про Android я получила хорошие отзывы,
вдохновилась и решила продолжать. Эта серия статей будет посвящена
написанию игры для Android.

Писать мы будем игру в пинг-понг.
Изначально задумывался арканоид, но для мануала получалось слишком
громоздко, так что я решила упростить до пинг-понга. Итак, есть
прямоугольное поле, на нем две ракетки, управляемые игроками, и мячик.
Мячик летает, отражаясь от ракеток и боковых стенок. Когда один игрок
не успевает отбить мячик, его противнику засчитывается очко. Игра
продолжается, пока один из игроков не наберет определенное число очков.
Вот такую игру мы и будем писать. Одна ракетка будет управляться
пользователем, другая — компьютером.

Создание проекта

Проект будем делать, как и в прошлый раз, в Eclipse. Создаём:

Создание проекта PingPong

Получили автоматически сгенерившийся HelloWorld. На форме у нас единственный элемент управления — TextView.
Но нам нужно разместить на форме компонент, который бы отрисовывал
игровое поле и обрабатывал нажатия клавиш. Среди стандартных такого
нет, так что придется создавать свой.

Surface

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

SurfaceView

SurfaceView унаследован от View и является элементом управления, предоставляющим область для рисования (Surface).
Суть в том, чтобы дать отдельному потоку возможность рисовать на
Surface, когда он захочет, а не только тогда, когда приложению
вздумается обновить экран. Понятие Surface очень похоже на Canvas, но
все же немного не то. Canvas — это область рисования на компоненте, а
Surface сам является компонентом, т.е. у Surface есть Canvas.

SurfaceView
является элементом управления, т.е. можно его непосредственно
разместить на форме. Однако, в этом случае толку толку от него будет
мало. Так что мы будем писать свой класс, унаследованный от
SurfaceView, а также класс для потока, который будет на нем рисовать.

SurfaceHolder

Интерфейс, с помощью которого происходит вся непосредственная работа с областью рисования. Выглядит это примерно так:

SurfaceHolder surfaceHolder;
...
Canvas canvas = surfaceHolder.lockCanvas(); // начали рисовать
 // рисуем
surfaceHolder.unlockCanvasAndPost(canvas); // закончили рисовать

SurfaceHolder.Callback

Интерфейс содержит функции обработки изменения состояния Surface:

  • surfaceCreated(SurfaceHolder holder) — первое создание Surface. Здесь можно, например, запускать поток, который будет рисовать на Surface.
  • surfaceChanged(SurfaceHolder holder, int format, int width, int height) — любое изменение Surface (например, поворот экрана).
  • surfaceDestroyed(SurfaceHolder holder) — уничтожение Surface. Здесь можно останавливать процесс, который рисует на Surface.

Класс для отображения игры

Итак, узнав, что такое Surface, можно двигаться дальше. Cоздаем класс GameView.java, унаследованный от SurfaceView и реализующий интерфейс SurfaceHolder.Callback.
Добавим интерфейсные функции и перегрузим конструктор. Кроме того,
следует завести в этом классе ссылку на SurfaceHolder. В результате
получится что-то вроде того:

GameView.java

public class GameView extends SurfaceView implements SurfaceHolder.Callback
{
    /**
     * Область рисования
     */
    private SurfaceHolder mSurfaceHolder;

    /**
     * Конструктор
     * @param context
     * @param attrs
     */
    public GameView(Context context, AttributeSet attrs)
    {
        super(context, attrs);

        // подписываемся на события Surface
        mSurfaceHolder = getHolder();
        mSurfaceHolder.addCallback(this);
    }

    @Override
    /**
     * Изменение области рисования
     */
    public void surfaceChanged(SurfaceHolder holder, int format, int width, int height)
    {
    }

    @Override
    /**
     * Создание области рисования
     */
    public void surfaceCreated(SurfaceHolder holder)
    {
    }

    @Override
    /**
     * Уничтожение области рисования
     */
    public void surfaceDestroyed(SurfaceHolder holder)
    {
    }
}

Теперь мы можем запросто писать в разметке формы такое:

main.xml

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent"
    >
    <com.android.pingpong.GameView
      android:id="@+id/game"
      android:layout_width="fill_parent"
      android:layout_height="fill_parent"/>
</FrameLayout>

И, запустив программу, увидим пустой экран. Теперь давайте что-нибудь нарисуем.

Класс для рисования

Поставим себе первую цель: нарисовать на экране прямоугольное поле размером 300 x 250.

Как было уже ранее сказано, все рисование должно производиться из отдельного потока. Создадим класс, GameManager, унаследованный от Thread.

GameManager.java

public class GameManager extends Thread
{
    private static final int FIELD_WIDTH = 300;
    private static final int FIELD_HEIGHT = 250;

   /** Область, на которой будем рисовать */
    private SurfaceHolder mSurfaceHolder;

    /** Состояние потока (выполняется или нет. Нужно,
 чтобы было удобнее прибивать поток, когда потребуется) */
    private boolean mRunning;

    /** Стили рисования */
    private Paint mPaint;

    /** Прямоугольник игрового поля */
    private Rect mField;

    /**
     * Конструктор
     * @param surfaceHolder Область рисования
     * @param context Контекст приложения
     */
    public GameManager(SurfaceHolder surfaceHolder, Context context)
    {
        mSurfaceHolder = surfaceHolder;
        mRunning = false;

        mPaint = new Paint();
        mPaint.setColor(Color.BLUE);
        mPaint.setStrokeWidth(2);
        mPaint.setStyle(Style.STROKE);

        int left = 10;
        int top = 50;
        mField = new Rect(left, top, left + FIELD_WIDTH, top + FIELD_HEIGHT);
    }

    /**
     * Задание состояния потока
     * @param running
     */
    public void setRunning(boolean running)
    {
        mRunning = running;
    }

    @Override
    /** Действия, выполняемые в потоке */
    public void run()
    {
        while (mRunning)
        {
            Canvas canvas = null;
            try
            {
                // подготовка Canvas-а
                canvas = mSurfaceHolder.lockCanvas();
                synchronized (mSurfaceHolder)
                {
                    // собственно рисование
                    canvas.drawRect(mField, mPaint);
                }
            }
            catch (Exception e) { }
            finally
            {
                if (canvas != null)
                {
                    mSurfaceHolder.unlockCanvasAndPost(canvas);
                }
            }
        }
    }
}

Стоит отдельно упомянуть о классе Paint.
Этот класс используется для хранения всяческих используемых при
рисовании стилей — цветов, толщины и стиля линий, шрифтов (это мы
рассмотрим позже) и тому подобного. В остальном код достаточно
прозрачен. Собственно рисование проходит всегда одинаково — лочим
Canvas, рисуем, разлочиваем.

Теперь надо запустить рисовательный поток в нашем контроле. Добавляем в класс соответствующее поле:

GameView.java

/**
 * Поток, рисующий в области
 */
private GameManager mThread;

В конструкторе GameView:

GameView.java

mThread = new GameManager(mSurfaceHolder, context);

При создании области рисования надо будет запустить наш поток:

GameView.java

public void surfaceCreated(SurfaceHolder holder)
{
    mThread.setRunning(true);
    mThread.start();
}

А при удалении — прибить:

GameView.java

public void surfaceDestroyed(SurfaceHolder holder)
{
    boolean retry = true;
    mThread.setRunning(false);
    while (retry)
    {
        try
        {
            // ожидание завершение потока
            mThread.join();
            retry = false;
        }
        catch (InterruptedException e) { }
    }
}

Теперь, запустив программу, видим следующее:

Нарисовали прямоугольное поле

Поворот экрана

Как уже было упомянуто, при повороте экрана вызывается обработчик surfaceChanged.
Впрочем, при создании surface он тоже вызывается. В параметрах можно
получить размеры доступной части экрана, что очень приятно, потому что
с помощью класса DisplayMetrics можно получить только полный размер экрана, куда еще входит верхнее поле, на котором рисовать нельзя).

Итак, в surfaceChanged мы будем пересчитывать положение нашего поля на экране. Добавим в GameManager такую функцию:

GameManager.java

/**
 * Инициализация положения объектов, в соответствии с размерами экрана
 * @param screenHeight Высота экрана
 * @param screenWidth Ширина экрана
 */
public void initPositions(int screenHeight, int screenWidth)
{
    int left = (screenWidth - FIELD_WIDTH) / 2;
    int top = (screenHeight - FIELD_HEIGHT) / 2;

    mField.set(left, top, left + FIELD_WIDTH, top + FIELD_HEIGHT);
}

Эта функция ставит наше игровое поле в центр экрана. Инициализацию положения mField в конструкторе GameManager можно вовсе убрать, оставив только:

GameManager.java

public GameManager(SurfaceHolder surfaceHolder, Context context)
{
    mSurfaceHolder = surfaceHolder;
    mRunning = false;

    mPaint = new Paint();
    mPaint.setColor(Color.BLUE);
    mPaint.setStrokeWidth(2);
    mPaint.setStyle(Style.STROKE);
    mField = new Rect();
}

Теперь в surfaceChanged можно написать следующее:

GameView.java

@Override
/**
 * Изменение области рисования
 */
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height)
{
    mThread.initPositions(height, width);
}

Теперь при изменении Surface (в том числе при его
создании) будет пересчитываться положение поля. Так что приложение
будет выглядеть так:

Эмулятор вертикально

Или так:

Эмулятор горизонтально

Итак

Мы
рассмотрели необходимые понятия, написали простое приложение, которое
умеет что-то рисовать, обработали поворот экрана. Исходники, как
обычно, прилагаются.