На прошлую статью про Android я получила хорошие отзывы, вдохновилась и решила продолжать. Эта серия статей будет посвящена написанию игры для Android.
Писать мы будем игру в пинг-понг. Изначально задумывался арканоид, но для мануала получалось слишком громоздко, так что я решила упростить до пинг-понга. Итак, есть прямоугольное поле, на нем две ракетки, управляемые игроками, и мячик. Мячик летает, отражаясь от ракеток и боковых стенок. Когда один игрок не успевает отбить мячик, его противнику засчитывается очко. Игра продолжается, пока один из игроков не наберет определенное число очков. Вот такую игру мы и будем писать. Одна ракетка будет управляться пользователем, другая — компьютером.
Создание проекта
Проект будем делать, как и в прошлый раз, в Eclipse. Создаём:
Получили автоматически сгенерившийся 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 (в том числе при его создании) будет пересчитываться положение поля. Так что приложение будет выглядеть так:
Или так:
Итак
Мы рассмотрели необходимые понятия, написали простое приложение, которое умеет что-то рисовать, обработали поворот экрана. Исходники, как обычно, прилагаются.
Отличные статьи, спасибо!
ОтветитьУдалитьУ меня тоже была проблема с перерисовкой View'a, но я ее решил, вызывая из другого потока по таймеру функцию View.postInvalidate()
Здравствуйте, Дарья. Я давно слежу за вашим творчеством и являюсь вашим тайным поклонником. Особенное впечатление произвели на меня Андроид-публикации. Дарья, как вам удается сочетать в себе внутреннюю красоту и стремление к знанием и внешнее очарование?
ОтветитьУдалитьСпасибо за статьи, Дарья! Очень помогают мне в изучении андроида :)
ОтветитьУдалитьИзвините за тупость, но как вы расположили эмулятор горизонтально?
ОтветитьУдалить2 sergey:
ОтветитьУдалитьCtrl+F11
Прощу прощение...
ОтветитьУдалитьНо я не совсем понял те действия, что выполняются в потоке:
public void run() {
while (mRunning) {
Получается, что пока mRunning == true, поток непрерывно рисует прямоугольник, хотя он и не изменяется. Разве это правильно ?
МегаДарья, а будет ли продолжение?
ОтветитьУдалитьИмел ввиду продолжение примеров под Андроид =)
ОтветитьУдалить2 Дмитрий:
ОтветитьУдалитьНе удивлюсь, если будет. Но для статей нужно время и сам девайс, а я сейчас не располагаю ни тем, ни другим.
Здравствуйте, Дарья.
ОтветитьУдалитьПрежде всего хочу поблагодарить вас за такие прекрасные статьи, они очень помогли мне - спасибо вам!
Не могли бы вы помочь мне разобраться в вашем проекте? Когда я запускаю код, который представлен выше (до "Поворот экрана"), то на эмуляторе появляется только пустое чёрное окно без синего прямоугольника. При перезапуске в консоль выдаётся предупреждение (Warning: Activity not started, its current task has been brought to the front). Вы можете подсказать, в чём ошибка или где её стоит искать?
В очередной раз, внимательно пересмотрев ваш код, ошибка нашлась :)
ОтветитьУдалитьПривет, извените за глупый вопрос.
ОтветитьУдалитьЯ скачал исходник игры, а как запустить игру на телефоне не могу понять? "Helo World" запустилось без проблем!
Спасибо, очень познавательная статья!
ОтветитьУдалитьIMHO тэг "игра" тут все-таки лишний :-D
ОтветитьУдалитьВы великолепны Дарья! =)
ОтветитьУдалитьТак держать! Покажите миру, что женщины тоже не плохо разбираются в программистике!
Опечаточка вышла. В предложении «Однако, в этом случае толку толку от него будет мало.» слово толку употреблено 2 раза подряд.
ОтветитьУдалитьЕсть очень неприятная проблема в этом примере. Если после старта нажать в эмуляторе или на устройстве кнопку "домой", то при попытке повторного старта выдается ошибка "Произошла неожиданная остановка приложения. Повторите попытку". При нажатии кнопки "назад" и повторном запуске такой проблемы нет.
ОтветитьУдалитьУдалось избавиться от этой проблемы добавлением finish() в onStop для activity.
ОтветитьУдалитьНе рисует прямоугольник даже..(( я только начинающий в чем может быть проблема?
ОтветитьУдалитьи как происходит доступ к другим классам через GanmeScreen??
ОтветитьУдалитькак сделать так чтоб размер стола был по всей ширине экрана?
ОтветитьУдалитья определяю размеры экрана в GameScreen.java и как мне их теперь передать в Gamemanager.java?
ОтветитьУдалитьДарья, спасибо за статью. Вопрос.. прорисовка ректа происходит в постоянном цикле? Ведь run() запущен всегда.
ОтветитьУдалитьЕсть такой метод вывода графики
ОтветитьУдалитьПример
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);
}
}
Чем Ваш метод лучше?
Здравствуйте! Очень интересная статья, спасибо! Есть вопрос, а можно ли залить черным цветом не весь экран а только часть? А в другой части, чтоб отображались обычные кнопки и т.д.
ОтветитьУдалить