пятница, 27 марта 2009 г.

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

На прошлую статью про 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 (в том числе при его создании) будет пересчитываться положение поля. Так что приложение будет выглядеть так:

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

Или так:

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

Итак

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

25 комментариев:

jeck_landin комментирует...

Отличные статьи, спасибо!
У меня тоже была проблема с перерисовкой View'a, но я ее решил, вызывая из другого потока по таймеру функцию View.postInvalidate()

Анонимный комментирует...

Здравствуйте, Дарья. Я давно слежу за вашим творчеством и являюсь вашим тайным поклонником. Особенное впечатление произвели на меня Андроид-публикации. Дарья, как вам удается сочетать в себе внутреннюю красоту и стремление к знанием и внешнее очарование?

Butterfly комментирует...

Спасибо за статьи, Дарья! Очень помогают мне в изучении андроида :)

sergey комментирует...

Извините за тупость, но как вы расположили эмулятор горизонтально?

darja комментирует...

2 sergey:

Ctrl+F11

Анонимный комментирует...

Прощу прощение...
Но я не совсем понял те действия, что выполняются в потоке:
public void run() {
while (mRunning) {

Получается, что пока mRunning == true, поток непрерывно рисует прямоугольник, хотя он и не изменяется. Разве это правильно ?

Dim комментирует...

МегаДарья, а будет ли продолжение?

Dim комментирует...

Имел ввиду продолжение примеров под Андроид =)

darja комментирует...

2 Дмитрий:
Не удивлюсь, если будет. Но для статей нужно время и сам девайс, а я сейчас не располагаю ни тем, ни другим.

Gelassen комментирует...

Здравствуйте, Дарья.
Прежде всего хочу поблагодарить вас за такие прекрасные статьи, они очень помогли мне - спасибо вам!

Не могли бы вы помочь мне разобраться в вашем проекте? Когда я запускаю код, который представлен выше (до "Поворот экрана"), то на эмуляторе появляется только пустое чёрное окно без синего прямоугольника. При перезапуске в консоль выдаётся предупреждение (Warning: Activity not started, its current task has been brought to the front). Вы можете подсказать, в чём ошибка или где её стоит искать?

Gelassen комментирует...

В очередной раз, внимательно пересмотрев ваш код, ошибка нашлась :)

Анонимный комментирует...

Привет, извените за глупый вопрос.
Я скачал исходник игры, а как запустить игру на телефоне не могу понять? "Helo World" запустилось без проблем!

alukardev комментирует...

Спасибо, очень познавательная статья!

Анонимный комментирует...

IMHO тэг "игра" тут все-таки лишний :-D

Анонимный комментирует...

Вы великолепны Дарья! =)
Так держать! Покажите миру, что женщины тоже не плохо разбираются в программистике!

OnDroid комментирует...

Опечаточка вышла. В предложении «Однако, в этом случае толку толку от него будет мало.» слово толку употреблено 2 раза подряд.

Анонимный комментирует...

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

Анонимный комментирует...

Удалось избавиться от этой проблемы добавлением finish() в onStop для activity.

Soichiro комментирует...

Не рисует прямоугольник даже..(( я только начинающий в чем может быть проблема?

Soichiro комментирует...

и как происходит доступ к другим классам через GanmeScreen??

Unknown комментирует...

как сделать так чтоб размер стола был по всей ширине экрана?

Unknown комментирует...

я определяю размеры экрана в GameScreen.java и как мне их теперь передать в Gamemanager.java?

Unknown комментирует...

Дарья, спасибо за статью. Вопрос.. прорисовка ректа происходит в постоянном цикле? Ведь run() запущен всегда.

Unknown комментирует...

Есть такой метод вывода графики

Пример

public class GraphicsView extends View{


public GraphicsView(Context context) {
super(context);

инициализация

}


@Override
protected void onDraw(Canvas canvas){
super.onDraw(canvas);

canvas.drawPaint(grndPaint);
canvas.drawText(String.valueOf(touchCounter), 10, 32, txtPaint);

}

@Override
public boolean onTouchEvent(MotionEvent event) {

int action;
action = event.getAction();
invalidate();

return true; //super.onTouchEvent(event);

}



}


Чем Ваш метод лучше?

Unknown комментирует...

Здравствуйте! Очень интересная статья, спасибо! Есть вопрос, а можно ли залить черным цветом не весь экран а только часть? А в другой части, чтоб отображались обычные кнопки и т.д.