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

Пишем игру для Android. Часть 2. Игровые объекты

Добавим в нашу игру экшена. Как известно, в пинг-понге три действующих лица: мячик и две ракетки. Имеет смысл реализовать соответствующие классы — Ball и Racquet. А, поскольку имеются некоторые свойства, присущие обоим этим сущностям (как то: расположение, изображение, размеры и т.д.), то можно сделать базовый класс под названием GameObject. Диаграмма классов будет такая:

Диаграмма классов

У всех игровых объектов есть:

  • mPoint — левый верхний угол прямоугольника, ограничивающего объект. С её помощью однозначно определяется положение объекта на плоскости
  • mImage — изображение объекта. Изображения обычно берутся из ресурсов приложения — /res/drawable
  • mHeight, mWidth — размеры изображения объекта. Вынесены как поля класса только для того, чтобы не загромождать код всякими mImage.getIntrinsicHeight() и mImage.getIntrinsicWidth()
  • mSpeed — скорость перемещения объекта (фактически, на сколько перемещается объект за один шаг)

Кроме того, все игровые классы умеют рисовать себя на указанном Canvas-е (метод draw) и вычислять своё следующее состояние (update). Причем, перемещение происходит так: вычисляется следующее состояние опорной точки (updatePoint), а потом туда переносится изображение. А, поскольку мячик и ракетка перемещаются по-разному, метод updatePoint сделан абстрактным.

В класс Ball добавлено поле mAngle — угол (в градусах) к оси Ox, под которым летит мячик. В класс Racquet — поля mDirection (направление, куда движется ракетка — вправо или влево, или вообще не движется) и mScore (количество очков у игрока).

Реализации классов

GameObject

GameObject.java

public abstract class GameObject
{
    // Константы для направлений
    public final int DIR_LEFT = -1;
    public final int DIR_RIGHT = 1;
    public final int DIR_NONE = 0;

    /** Координаты опорной точки */
    protected Point mPoint;

    /** Высота изображения */
    protected int mHeight;

    /** Ширина изображения */
    protected int mWidth;

    /** Изображение */
    private Drawable mImage;

    /** Скорость */
    protected int mSpeed;

    /**
     * Конструктор
     * @param image Изображение, которое будет обозначать данный объект
     */
    public GameObject(Drawable image)
    {
        mImage = image;
        mPoint = new Point(0, 0);
        mWidth = image.getIntrinsicWidth();
        mHeight = image.getIntrinsicHeight();
    }

    /** Перемещение опорной точки */
    protected abstract void updatePoint();

    /** Перемещение объекта */
    public void update()
    {
        updatePoint();
        mImage.setBounds(mPoint.x, mPoint.y, mPoint.x + mWidth, mPoint.y + mHeight);
    }

    /** Отрисовка объекта */
    public void draw(Canvas canvas)
    {
        mImage.draw(canvas);
    }
    /** Задает левую границу объекта */
    public void setLeft(int value) { mPoint.x = value; }

    /** Задает правую границу объекта */
    public void setRight(int value) { mPoint.x = value - mWidth; }

    /** Задает верхнюю границу объекта */
    public void setTop(int value) { mPoint.y = value; }

    /** Задает нижнюю границу объекта */
    public void setBottom(int value) { mPoint.y = value - mHeight; }

    /** Задает абсциссу центра объекта */
    public void setCenterX(int value) { mPoint.x = value - mHeight / 2; }

    /** Задает левую ординату центра объекта */
    public void setCenterY(int value) { mPoint.y = value - mWidth / 2; }
}

Ball

Ball.java

public class Ball extends GameObject
{
    private static final int DEFAULT_SPEED = 3;
    private static final int PI = 180;

    /** Угол, который составляет направление полета шарика с осью Ox */
    private int mAngle;

    /**
     * @see com.android.pingpong.objects.GameObject#GameObject(Drawable)
     */
    public Ball(Drawable image)
    {
        super(image);

        mSpeed = DEFAULT_SPEED; // задали скорость по умолчанию
        mAngle = getRandomAngle(); // задали случайный начальный угол
    }

    /**
     * @see com.android.pingpong.objects.GameObject#updatePoint()
     */
    @Override
    protected void updatePoint()
    {
        double angle = Math.toRadians(mAngle);

        mPoint.x += (int)Math.round(mSpeed * Math.cos(angle));
        mPoint.y -= (int)Math.round(mSpeed * Math.sin(angle));
    }

    /** Генерация случайного угла в промежутке [95, 110]U[275,290] */
    private int getRandomAngle()
    {
        Random rnd = new Random(System.currentTimeMillis());

        return rnd.nextInt(1) * PI + PI / 2 + rnd.nextInt(15) + 5;
    }
}

Racquet

Racquet.java

public class Racquet extends GameObject
{
    private static final int DEFAULT_SPEED = 3;

    /** Количество заработанных очков */
    private int mScore;

    /** Направление движения */
    private int mDirection;

    /** Задание направления движения*/
    private void setDirection(int direction)
    {
        mDirection = direction;
    }

    /**
     * @see com.android.pingpong.objects.GameObject#GameObject(Drawable)
     */
    public Racquet(Drawable image)
    {
        super(image);

        mDirection = DIR_NONE; // Направление по умолчанию - нет
        mScore = 0; // Очков пока не заработали
        mSpeed = DEFAULT_SPEED; // Задали скорость по умолчанию
    }

    /**
     * @see com.android.pingpong.objects.GameObject#updatePoint()
     */
    @Override
    protected void updatePoint()
    {
        mPoint.x += mDirection * mSpeed; // двигаем ракетку по оси Ox в соответствующую сторону
    }
}

Отображение игровых объектов

Рисуем картинки

Ну вроде классы наши готовы, можно теперь их использовать в программе. Но сначала надо нарисовать картинки, для наших объектов. Картинки должны быть в png. У меня получилось так:

МячикМячик
Наша ракеткаНаша ракетка
Pакетка противникаPакетка противника

Берем все это счастье, и кидаем в /res/drawable, где у нас хранятся всякие такие ресурсы.

Создаем игровые объекты

Теперь нам надо где-то создать экземпляры наших классов, чтобы они там жили, обновлялись и отображались на экране. Очевидно, что тут нам поможет GameManager. Итак, добавим в него такие поля:

GameManager.java

/** Ресурсы приложения */
private Resources mRes;

/** Мячик */
private Ball mBall;

/** Ракетка, управляемая игроком */
private Racquet mUs;

/** Ракетка, управляемая компьютером*/
private Racquet mThem;

В конструкторе инициализируем их:

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);
Resources res = context.getResources(); mField = new Rect(); mBall = new Ball(res.getDrawable(R.drawable.ball)); mUs = new Racquet(res.getDrawable(R.drawable.us)); mThem = new Racquet(res.getDrawable(R.drawable.them));
}

Расставляем их по местам

Однако, этого мало. У всех игровых объектов опорная точка задана в начале координат, т.е. они сейчас все в куче, и надо бы их растащить по местам. И самый лучший метод, где можно это сделать — initPositions():

GameManager.java

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);
// мячик ставится в центр поля mBall.setCenterX(mField.centerX()); mBall.setCenterY(mField.centerY()); // ракетка игрока - снизу по центру mUs.setCenterX(mField.centerX()); mUs.setBottom(mField.bottom); // ракетка компьютера - сверху по центру mThem.setCenterX(mField.centerX()); mThem.setTop(mField.top);
}

Заставляем их что-то делать

Итак, объекты мы создали, теперь надо заставить их что-то делать. Понятно, что все изменения должны происходить в цикле, который работает в методе run(). На каждом шаге цикла мы должны обновить состояния объектов и отобразить их. Добавим две функции:

GameManager.java

/** Обновление объектов на экране */
private void refreshCanvas(Canvas canvas)
{
    // рисуем игровое поле
    canvas.drawRect(mField, mPaint);

    // рисуем игровые объекты
    mBall.draw(canvas);
    mUs.draw(canvas);
    mThem.draw(canvas);
}

/** Обновление состояния игровых объектов */
private void updateObjects()
{
    mBall.update();
    mUs.update();
    mThem.update();
}

А в методе run() будут вызовы этих методов:

GameManager.java

public void run()
{
    while (mRunning)
    {
        Canvas canvas = null;
        try
        {
            // подготовка Canvas-а
            canvas = mSurfaceHolder.lockCanvas();
            synchronized (mSurfaceHolder)
            {
updateObjects(); // обновляем объекты refreshCanvas(canvas); // обновляем экран sleep(20);
} } catch (Exception e) { } finally { if (canvas != null) { mSurfaceHolder.unlockCanvasAndPost(canvas); } } } }

Ну все, вроде сделали, можно запускать. Ракетки не двигаются, потому что ими пока никто не управляет, а вот шарик вполне себе летает. Правда, оставляет при этом за собой шлейф:

Бага

Делаем фон

Это всё из-за того, что мы не очищаем экран перед очередной отрисовкой. А очищать экран это тоже не так-то просто. Ничего вроде canvas.clear() я не нашла, так что пришлось извращаться. Суть в том, что мы заводим специальный Bitmap, при инициализации Surface задать ему размеры на весь экран, а потом в refreshCanvas выводить его. При желании можно загрузить в этот Bitmap какое-нибудь изображение из ресурсов.

Итак, заводим поле:

GameManager.java

/** Фон */
private Bitmap mBackground;

Инициализируем его в initPositions

GameManager.java

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);
mBackground = Bitmap.createBitmap(screenWidth, screenHeight, Bitmap.Config.RGB_565);
// мячик ставится в центр поля mBall.setCenterX(mField.centerX()); mBall.setCenterY(mField.centerY()); // ракетка игрока - снизу по центру mUs.setCenterX(mField.centerX()); mUs.setBottom(mField.bottom); // ракетка компьютера - сверху по центру mThem.setCenterX(mField.centerX()); mThem.setTop(mField.top); }

И отрисовываем в refreshCanvas:

GameManager.java

private void refreshCanvas(Canvas canvas)
{
// вывод фонового изображения canvas.drawBitmap(mBackground, 0, 0, null);
// рисуем игровое поле canvas.drawRect(mField, mPaint); // рисуем игровые объекты mBall.draw(canvas); mUs.draw(canvas); mThem.draw(canvas); }

И получаем примерно вот такую картину:

Движущийся шар

Итак

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

Исходники примера

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

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

По поводу отсутствующего Canvas.clear - а что, аналога FillRect там нет?

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

У Canvas есть метод drawRGB(r, g, b), который полностью заполняет canvas указанным цветом

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

При желании можно загрузить в этот Bitmap какое-нибудь изображение из ресурсов.
Что то я здесь туплю... Не могли бы вы написать код битмапа только уже с полноценной картинкой из ресурсов

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

2 sergey:
Например, так:

Bitmap b = BitmapFactory.decodeResource(context.getResources(), R.drawable.something);

Mr. Wasserman комментирует...

для очискти canvas можно испльзовать

canvas.drawColor( 0, PorterDuff.Mode.CLEAR );

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

Спасибо за ваш блог, только начинаю разбираться в написании приложений под андроид, подписался.
замечания:
может плохо читал, но не заметил привязки скорости шарика к таймеру. Для расчета позиции шарика так и напрашивается векторная математика.

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

а почему sleep(20) внутри synchronized а не за ним?

Alex Bonel комментирует...

Даша, скажите пожалуйста, в какой программе диаграмму классов строили, судя по значкам, это эклипсовский плагин, как называется?

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

Mr. Wasserman
Только правильней запись (во всяком случае по другому у меня не работало):
canvas.drawColor( 0, Mode.CLEAR );

и импортировать
import android.graphics.PorterDuff.Mode;

Anton Gladyshev комментирует...

Дарья, отличные статьи, спасибо!

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

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

Дарья подскажите пожалуйста какой плагин для Eclipse вы используете для построения диаграммы классов?

Sergey Pavlenko комментирует...

Похоже на плагин AmaterasUML

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

Вопрос от новичка:
Для чего вызываем sleep() в методе run()?
Заранее благодарен.

Дарья, спасибо за статьи.