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

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

27.03.2009

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

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

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

Помнится, мы заводили в классе 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 — хелпер для рисования паузы. Я уже не буду приводить к нему листинг, там все просто.

Итак

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

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