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

Пишем игру для Android. Часть 4. Игровой процесс

В этой части мы напишем обработку выигрышей-проигрышей, реализуем подсчет очков, а также сделаем, чтобы игру можно было ставить на паузу. Собственно, пауза тут несколько не в тему, но девать ее некуда, так что сделаем ее в этой части.

Обработка проигрыша

Помнится, мы заводили в классе Racquet поле mScore, в котором собирались хранить количество очков у игрока. Теперь самое время начать использовать это поле.

Итак, в начале игры количество очков у обоих игроков пусто. Когда игрок не успевает отбить мяч, его противнику засчитывается очко, мячик и ракетки возвращаются на исходные позиции. Игра продолжается, пока какой-нибудь из игроков не наберет N очков. N мы пока что объявим константой, а в следующей части вынесем в настройки.

Проверка проигрыша должна осуществляться также в методе updateObjects() GameManager-а. Описанная нами логика запишется так:

GameManager.java

private void updateObjects()
{
    ...
// проверка проигрыша if (mBall.getBottom() < mThem.getBottom()) { mUs.incScore(); reset(); } if (mBall.getTop() > mUs.getTop()) { mThem.incScore(); reset(); }
}

Racquet.incScore() увеличивает на 1 количество очков у игрока:

Racquet.java

/** Увеличить количество очков игрока */
public void incScore()
{
    mScore++;
}

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

GameManager.java

private void reset()
{
    // ставим мячик в центр
    mBall.setCenterX(mField.centerX());
    mBall.setCenterY(mField.centerY());
    // задаем ему новый случайный угол
    mBall.resetAngle();

    // ставим ракетки в центр
    mUs.setCenterX(mField.centerX());
    mThem.setCenterX(mField.centerX());

    // делаем паузу
    try
    {
        sleep(LOSE_PAUSE);
    }
    catch (InterruptedException iex)
    {
    }
}

LOSE_PAUSE — это константа класса GameManager, в которой задается длина паузы в миллисекундах (у меня она равна 2000). Метод же resetAngle() класса Ball выглядит следующим образом:

Ball.java

/** Задает новое случайное значение угла */
public void resetAngle()
{
    mAngle = getRandomAngle();
}

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

Вывод количества очков

Вывод текста на экран производится с помощью метода drawText(String text, float x, float y, Paint paint) класса Canvas. Как можно заметить, стили текста задаются с помощью экземпляра класса Paint. Где-то в первой части мы создавали в GameManager такое поле mPaint, где хранились стили для рисования игрового поля. Для вывода текста можно использовать это же поле, и при каждой перерисовке экрана задавать ему стили сначала для игрового поля, а потом для текста. А можно завести отдельный экземпляр Paint для хранения стилей текста:

GameManager.java

private Paint mScorePaint;

Инициализировать его в конструкторе:

GameManager.java

public GameManager(SurfaceHolder surfaceHolder, Context context)
{
    mSurfaceHolder = surfaceHolder;
    Resources res = context.getResources();
    mRunning = false;

    // стили для рисования игрового поля
    mPaint = new Paint();
    mPaint.setColor(Color.BLUE);
    mPaint.setStrokeWidth(2);
    mPaint.setStyle(Style.STROKE);
// стили для вывода счета mScorePaint = new Paint(); mScorePaint.setTextSize(20); mScorePaint.setStrokeWidth(1); mScorePaint.setStyle(Style.FILL); mScorePaint.setTextAlign(Paint.Align.CENTER);
// игровые объекты 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)); }

А непосредственно вывод счета игры производится в методе, где происходит вся отрисовка текущей игровой ситуации — 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);
// вывод счета mScorePaint.setColor(Color.RED); canvas.drawText(String.valueOf(mThem.getScore()), mField.centerX(), mField.top - 10, mScorePaint); mScorePaint.setColor(Color.GREEN); canvas.drawText(String.valueOf(mUs.getScore()), mField.centerX(), mField.bottom + 25, mScorePaint);
}

Правда, совсем уж без изменения стиля не обошлось. Наши очки мы рисуем зелёным, а очки противника — красным. Запустив, увидим примерно такую картину:

Выводится количество очков

Использование пользовательских шрифтов

А теперь нам захотелось использовать для вывода счета какой-нибудь наш красивый шрифт. Рассмотрим, как это можно сделать.

В нашем проекте есть такая папка assets, там хранятся такие ресурсы, как TrueType-шрифты, возможно, какие-то большие тексты и т.д.. Основное отличие их от ресурсов, которые хранятся в папке res — это то, что используются они гораздо реже, и доставать их оттуда сложнее. Ресурсы из res можно запросто достать с помощью класса R, а assets вытаскиваются с помощью специального класса AssetManager.

Итак, создадим в папке assets папку fonts и кинем туда шрифт под названием Mini.ttf. Теперь, чтобы достать этот шрифт и использовать его для вывода количества очков, достаточно добавить в инициализацию mScorePaint в конструкторе одну строчку:

GameManager.java

mScorePaint.setTypeface(Typeface.createFromAsset(context.getAssets(), "fonts/Mini.ttf"));

context.getAssets() получит менеджер ресурсов (AssetManager) для данного приложения, откуда потом будет можно загрузить шрифт по указанному пути. Стоит обратить внимание, что путь является case-sensitive, т.е. "fonts/mini.ttf" уже ничего не загрузит.

Загружен пользовательский шрифт

Неприятность

И всё бы хорошо, но теперь время от времени стали возникать ситуации, когда в начале игры у одного из игроков выводится не 0 очков, а 1. Я так понимаю, что проблемы возникают в самом начале программы, перед initPositions, когда у игровых объектов координаты еще не заданы, а updateObjects уже вызывается. Чтобы исправить положение, заведем в классе GameManager еще одно булево поле mInitialized, в конструкторе зададим как false, а в initPositions присвоим ему true. Тогда в run можно написать так:

GameManager.java

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

Теперь гарантированно не будет происходить никаких проверок, пока не будут инициализированы координаты игровых объектов. Проблема решена.

Обработка окончания игры

Прежде всего, нам следует завести в GameManager переменную, где бы хранилось количество очков, до которого идет игра. Заведем такую переменную и сразу сеттер к ней. Итак:

GameManager.java

/** Максимальное число очков, до которого идет игра */
private static int mMaxScore = 5;

public static void setMaxScore(int value)
{
    mMaxScore = value;
}

Саму проверка на окончание игры можно поместить как в метод updateObjects(), так и прямо в run(). Но, думаю, правильнее именно в updateObjects():

GameManager.java

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

    // обработка столкновений
    ...

    // проверка проигрыша
    ...

// проверка окончания игры if (mUs.getScore() == mMaxScore mThem.getScore() == mMaxScore) { this.mRunning = false; }
}

Напомню, что метод run выглядит так:

GameManager.java

public void run()
{
    while (mRunning)
    {
        // обновление и отрисовка объектов
    }
}

То есть, когда mRunning станет равным false, поток завершится. Раз он завершился — игра закончена, и надо вывести на экран ее результаты. Так что логично видеть в методе run() что-то вроде:

GameManager.java

public void run()
{
    while (mRunning)
    {
        // обновление и отрисовка объектов
    }
    // рисование GameOver
    ...
}

А теперь разберемся, как это рисование может выглядеть. Как известно, при рисовании мы лочим Canvas, рисуем, и затем разлочиваем. При этом еще нужно отловить возможные исключения. Получается куча кода, которая появляется при каждом рисовании и сильно загромождает текст программы. Естественно, хочется вынести все это в метод-обертку и передавать туда ссылку на функцию, осуществляющую собственно рисование. На C# это выглядело бы так:

delegate void DrawFunction(Canvas canvas);

private void draw(DrawFunction something)
{
    Canvas canvas = null;
    try
    {
        canvas = mSurfaceHolder.lockCanvas();
        synchronized (mSurfaceHolder)
        {
            something(canvas);
        }
    }
    }
    catch (Exception e) { }
    finally
    {
        if (canvas != null)
        {
            mSurfaceHolder.unlockCanvasAndPost(canvas);
        }
    }
}

Но здесь нам не C#, здесь климат иной, и делегатов нет. Однако, как мне подсказал товарищ xeye, подобный код можно написать. Итак, добавим в GameManager такой интерфейс:

GameManager.java

private interface DrawHelper
{
    void draw(Canvas canvas);
}

И такой метод, куда мы вынесем всю работу по подготовке canvas-а:

GameManager.java

private void draw(DrawHelper helper)
{
    Canvas canvas = null;
    try
    {
        // подготовка Canvas-а
        canvas = mSurfaceHolder.lockCanvas();
        synchronized (mSurfaceHolder)
        {
            if (mInitialized)
            {
                helper.draw(canvas);
            }
        }
    }
    catch (Exception e) { }
    finally
    {
        if (canvas != null)
        {
            mSurfaceHolder.unlockCanvasAndPost(canvas);
        }
    }
}

Теперь можно завести конкретные реализации DrawHelper на все случаи жизни. Я добавляю их две:

GameManager.java

/** Хелпер для перерисовки экрана */
private DrawHelper mDrawScreen;

/** Хелпер для рисования результата игры*/
private DrawHelper mDrawGameover;

Инициализирую в конструкторе таким образом:

GameManager.java

public GameManager(SurfaceHolder surfaceHolder, Context context)
{
    ...
   
    // функция для рисования экрана
    mDrawScreen = new DrawHelper()
    {
        public void draw(Canvas canvas)
        {
            refreshCanvas(canvas);
        }
    };

    // функция для рисования результатов игры
    mDrawGameover = new DrawHelper()
    {
        public void draw(Canvas canvas)
        {
            // Вывели последнее состояние игры
            refreshCanvas(canvas);
           
            // смотрим, кто выиграл и выводим соответствующее сообщение
            String message = "";
            if (mUs.getScore() > mThem.getScore())
            {
                mScorePaint.setColor(Color.GREEN);
                message = "You won";
            }
            else
            {
                mScorePaint.setColor(Color.RED);
                message = "You lost";
            }
            mScorePaint.setTextSize(30);
            canvas.drawText(message, mField.centerX(), mField.centerY(), mScorePaint);               
        }
    };
}

После этого метод run() преображается до неузнаваемости:

GameManager.java

/** Действия, выполняемые в потоке */
public void run()
{
    while (mRunning)
    {
        if (mInitialized)
        {
            updateObjects(); // обновляем объекты
            draw(mDrawScreen);
        }
    }
    draw(mDrawGameover);
}

И сразу результат:

Результат игры

Пауза

Тут совсем кратко. Объявим в классе GameManager поле:

GameManager.java

/** Стоит ли приложение на паузе */
private boolean mPaused;

Если приложение на паузе, поток работает "вхолостую", т.е. состояния объектов не меняются и вообще ничего не происходит. Это значит, в методе run() будет следущее:

GameManager.java

public void run()
{
    while (mRunning)
    {
if (mPaused) continue;
if (mInitialized) { updateObjects(); // обновляем объекты draw(mDrawScreen); } } draw(mDrawGameover); }

Будем ставить приложение на паузу, если нажата средняя клавиша джойстика:

GameManager.java

public boolean doKeyDown(int keyCode)
{
    switch (keyCode)
    {
        case KeyEvent.KEYCODE_DPAD_LEFT:
        case KeyEvent.KEYCODE_A:
            mUs.setDirection(GameObject.DIR_LEFT);
            return true;
        case KeyEvent.KEYCODE_DPAD_RIGHT:
        case KeyEvent.KEYCODE_D:
            mUs.setDirection(GameObject.DIR_RIGHT);
            return true;
case KeyEvent.KEYCODE_DPAD_CENTER: mPaused = !mPaused; draw(mDrawPause); return true;
default: return false; } }

mDrawPause — хелпер для рисования паузы. Я уже не буду приводить к нему листинг, там все просто.

Итак

У нас уже совсем готовая игра. Можно играть, выигрывать, проигрывать, ставить на паузу.

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

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

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

Посмотрел ряд статей по созданию пинпонга ... общее впечатление очень хорошее, давно хочу начать писать для андроида, вот только до Java всё руки не доходят.

Добавил в фэйворит, думаю пригодится

p.s ... только писать лучше YOU LOSE, а не lost, чтобы совсем по игровому, или олдскульно GAME OVER ^_^

vyhuhol' комментирует...

нитку лучше ставить на паузу через wait/notify, иначе будет жрать проц потихоньку

vyhuhol' комментирует...

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

public class DelegateExample {

interface MyDelegate {
void e(StringBuilder canvas);
}

void draw(MyDelegate delegate) {
StringBuilder canvas = null;
try {
canvas = new StringBuilder(1000);
canvas.append("Prepare Canvas:");
delegate.e(canvas);
System.out.println(canvas);
} catch (Exception e) {
e.printStackTrace();
} finally {
// something
}
}

public static void main(String[] args) {
DelegateExample example = new DelegateExample();

// declate delegate instance in place, through anonymous class
example.draw(new MyDelegate(){ public void e(StringBuilder canvas) {
canvas.append("Delegate1");
canvas.append("...Delegate1...");
canvas.append(">>>Delegate1<<<");
}});

//example.draw(someExternalDelegateInstance);
}
}

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

2 Stretch
А мне вот гуглопереводчик сказал, что надо говорить You Lost. И игрушки из детства я помню, где так писали. Хотя остатки моих знаний английского сомневаются, что это правильно.

2 xeye:
Спасибо за ликбез :) Хотя не сказала бы, что отличия небольшие. Делегат - это всего лишь ссылка на функцию, и объявляется он прямо в классе. А тут под каждый предполагаемый делегат надо городить целый интерфейс.
А вот с wait/notify у меня ничего не получилось. Я понимаю, что лучше паузить весь поток, но с наскока сделать не получилось, полезли эксепшены, и я решила отложить разбирательство с ними на потом.

vyhuhol' комментирует...

http://www.exampledepot.com/egs/java.lang/PauseThread.html

в .нет объявление делегата внутри класса - это чисто синтаксис. за сценой там так же создается отдельный интерфейс. я не спорю, что так лаконичнее, но явовский способ вполне юзабелен :)

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

2 xeye
Да, Вы правы. Спасибо!

Strech комментирует...
Этот комментарий был удален автором.
Strech комментирует...

В порыве написал комент о том что гугл делают рус. сервисы слабже родных, удалил.

Вы видимо спрашивали у гугла "Проиграл", а попробуйте спросить "Ты проиграл"

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

Какой хитрый гугл :) Я пользуюсь гуглячьим джаббер-ботом, у нас с ним такой диалог состоялся:
> вы выиграли
ru2en> you have won
> ты выиграл
ru2en> you won
> вы проиграли
ru2en> you lost
> ты проиграл
ru2en> you lose
Так что на гугла надейся, а сам не плошай. lose - это инфинитив, и "you lose" - это настоящее время, т.е. "Вы проигрываете". А "you lost" - обозначение свершившегося факта, "Вы проиграли". Я бы даже You have lost написала, но как-то много букв.
В общем, надо было по-русски писать :)

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

Добрый день, Дарья! Я собираюсь написать свой первый вирус для Андроид-платформы. Можно я назову его в вашу честь? (в хорошем смысле конечно же, опустим тот факт, что это вирус). Я думаю, это будет наилучшим признанием вашего таланта с моей стороны. Какое название вам наиболее импонирует - "DarDroid" или скромное "MegaDasha"? С уважением, тайный поклонник.

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

Только если вирус хороший будет. А вообще неправославное это занятие - вирусы писать.

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

Опять опечатка: «Саму проверка на окончание....» проверкУ =)

Не самая лучшая идея делать проверку if(mInitialized) в главном цикле приложения. Фактически событие происходит только 1 раз, но проверка его будет происходить по 50 раз в секунду с нулевой вероятностью возникновения. В более крупных приложениях такие вещи будут хавать заметный процент ресурсов.

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

И ещё один косяк в листинге:
// проверка окончания игры
if (mUs.getScore() == mMaxScore mThem.getScore() == mMaxScore)


Пропущено «||» между «mUs.getScore() == mMaxScore» и «mThem.getScore() == mMaxScore».

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

Спасибо за хорошую статью! Скажите, как можно реализовать обработку нажатий пальцем на сенсорный экран? Я нашел небольшой пример (http://habrahabr.ru/blogs/android_development/111405/), но как его прикрутить к этой игре не знаю.

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

Здрасте. Понимаю что это было написано давно... но я вот тоже пытаюсь научиться писать игры..У меня проблема и пишу в надежде что может быть поможете. По вашему примеру.. я пытаюсь нарисовать тайл карту (tile map) она где то с 40-50 плиток складываеться.. Так вот каждая плитка это как 1 обьект. Если это всё впихнуть в рефреш канвас то собсно всё лагает ужасно( оно и не удивительно, столько обьектов перерисоывать) .Может быть есть какой то варинат нормально его нарисовать ?. Просто у вас здесь только 3-6 обьектов. Особо ничего не заметишь (

dajver комментирует...
Этот комментарий был удален автором.