Глава 4 Исследование 2D графики
До сих пор, мы рассматривали только фундаментальные понятия и общее устройство ОС Android, а так же узнали, как создать простой интерфейс с кнопками и диалогами. Мы практически закрепили свои познания на примерах.
Хорошая графика добавляет немного выразительности к любому приложению. Android содержит одну из самых мощных нативных библиотек для графики на мобильных устройствах. Фактически, она состоит из двух частей: одна для плоской графики и другая для трехмерной.[1]
В этой главе, мы рассмотрим 2D-графику и применим эти знания для того чтобы обеспечить игру Sudoku графикой. Глава 10, 3D графика в OpenGL, расскажет о использовании 3D -графики и библиотеки OpenGL ES.
4.1 Основы
Android поддерживает двухмерную графику в своем пакете android.graphics. С основным понятиями классов, таких как цвет и холст, и рисованием в реальном времени вы ознакомитесь далее.
Цвет
Цвета в Android представлены 4 числами, по дному для альфа-канала, красному, зеленому, и синему (ARGB). Каждый компонент может иметь 256 возможных
значений, или 8 бит, поэтому цвета обычно упакованы в трицатидвухразрядные целые. Для эффективности Android использует целые 32-х разрядные числа вместо экземпляров класса Color.
Красный, зеленый, и синий цвета это понятно, а вот понятие альфа-канала возможно нет. Альфа-канал – это измерение прозрачности. Самое низкое значение, 0, соответствует тому, что цвет полностью прозрачен. Если альфа-канал равен 0, цвет в RGB не имеет никакого значения. Самое высокое значение, 255 показывает цвет полностью. Значения в середине диапазона используются для просвечивания или полупрозрачности. Эти значения препятствуют нам увидеть некоторые из объектов, нарисованных под ними.
Для того чтобы создать цвет, необходимо использовать одну из статических констант из класса Color:
int color = Color.BLUE; // solid blue
или если вам необходима прозрачность, то вы можете использовать один из статических методов фабрики:
// Translucent purple color = Color.argb(127, 255, 0, 255);
По возможности, объявляйте все ваши цвета в ресурсах в файле XML, вам будет удобно их изменять:
#7fff00ff
Мы можем ссылаться на цвета, объявленные в файле XML, так же, как мы делали в главе 3, или их можно использовать в коде Java:
color = getResources().getColor(R.color.mycolor);
Метод getResources() возвращает тип ResourceManager для текущей activity, а getColor() возвращает цвет по идентификатору ресурса.
Рисование (Класс Paint)
Один из самых важных классов в графической библиотеке Android это класс Paint. Он содержит тип, цвет, и другую необходимо информацию для рисования графики включая bitmaps, текст, и геометрические формы. Когда вы рисуете что-то на экране сплошным цветом, вы устанавливаете цвет с помощью метода Paint.setColor(). Например:
cPaint.setColor(Color.LTGRAY);
Тут используется предопределенное значение цвета - серый.
Холст (Класс Canvas)
Класс Canvas представляет собой поверхность на которой происходит рисование. Первоначально холст использовался для рисования транспарантов для проекторов. Методы из класса Canvas предоставляют вам возможность нарисовать линии, прямоугольники, круги, или другую произвольную графику на поверхности.
В Android, экран Activity, который отображается пользователю, состоит из совокупности Canvas. Вам дается возможность рисовать на холсте, переопределяя метод View.onDraw(). Единственный параметр у метода onDraw() - canvas на которой вы будите рисовать. Приведем пример activity с именем Graphics, которая содержит view, именуемый GraphicsView:
public class Graphics extends Activity { @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(new GraphicsView(this)); } static public class GraphicsView extends View { public GraphicsView(Context context) { super(context); } @Override protected void onDraw(Canvas canvas) { // Drawing commands go here } }
Мы напишем несколько команд для рисования в методе onDraw( ) в следующем разделе.
Линии (Класс Path)
Класс Path содержит комплект команд для векторного рисования, например, команд лдя рисования линий, прямоугольников, и кривых. Нарисуем окружность:
circle = new Path(); circle.addCircle(150, 150, 100, Direction.CW);
Рисунок 4.1: Рисование текста вокруг окружности
Этот пример, который объявляет окружность в месте, с координатами x=150, y=150 и с радиусом в 100 пикселов. Теперь, напишем текст по периметру окружности:
private static final String QUOTE = "Now is the time for all " + "good men to come to the aid of their country." ; canvas.drawPath(circle, cPaint); canvas.drawTextOnPath(QUOTE, circle, 0, 20, tPaint);
Вы можете увидеть результат на рисунке 4.1. В виду того что окружность была нарисована по часовой стрелке (Direction.CW), текст также был нарисован по часовой стрелке. Если вы хотите получить “невероятные” эффекты, то Android обеспечивает несколько типов PathEffect, позволяющие вам делать такие вещи, как случайные линии, размытие, ломаные линии и др.
Визуализация (Класс Drawable)
В Android, класс Drawable используется для визуальализации элементов таких как битовые карты (bitmap) или для раскраски сплошным цветом. Вы можете совместить объекты drawables с другой графикой, или вы можете использовать их в виджетах интерфейсов (например, как фон для кнопки или вьевера).
Drawables может содержать разные объекты:
- Bitmap: Изображение формата PNG или JPEG.
- NinePatch: изображение PNG, разделенное на 9 разделов. Этот объект используется для bitmap-кнопок меняющих размер.
- Shape: команды для векторного рисования, основанные на Path. Это вид SVG.
- Layers: Контейнер для потомков drawables в некотором z-буфере.
- States: Контейнер показывает один из своих потомков drawables по битовой маске. Можно использовать для установки различных режимов выбора и принятия фокуса для кнопок.
- Levels: Контейнер показывает только один из своих потомков drawables на определенном уровне (целом числе). Используется, например, для изображения батареи или в датчике силы сигнала.
- Scale: Контейнер для одного потомка drawable, применяемого для изменения размера. Может применяться, например, для масштабирования изображения в просмоторщике изображений.
Drawables почти всегда объявлен в XML. Приведем пример, где drawable будет объявлен для того чтобы быть залить градиентом от одного цвета к другому (в данном случае от белого к серому цвету). Направление градиента 270 градусов с верху вниз. Это будет использоваться для фона в view:
Для того чтобы использовать градиент, необходимо объявить атрибут в XML содержимом: android: background=, или вызвать метод Canvas.setBackgroundResource() в методе вьевера onCreate():
setBackgroundResource (R.drawable.background);
Пример градиента в GraphicsView показан на рисунке 4.2
Рисунок 4.2: Использование градиента в фоне объявленного в XML
4.2 Добавлять графики к Sudoku
Время применить наши знания к примеру Sudoku. В главе 3, игра Sudoku имела главный экран, диалоговое окно “О программе” и путь для начала новой игры. Но мы пропустили самую важную часть: саму игру! Мы будем использовать нативную библиотеку 2D-графики для того чтобы реализовать эту часть.
Нюансы Судоку
Много лет после того как игра была опубликована в Соединенных Штатах, судоку было опубликовано издателем Nikoli, которое дало ему очень звучное современное название Sudoku (что значит «первый номер» в японском языке). Под этим именем оно и захватило весь мир и вошло в историю. Печально, автор Garns умер в 1989 не увидев, что его творение приобрело всемирную популярность.
Начало игры
Во-первых нам надо добавить код для начала игры. Метод startGame() с одним параметром: уровень сложности, выбранный из списка.
Объявление:
/** Start a new game with the given difficulty level */ private void startGame(int i) { Log.d(TAG, "clicked on " + i); Intent intent = new Intent(Sudoku.this, Game.class); intent.putExtra(Game.KEY_DIFFICULTY, i); startActivity(intent); }
Игровая часть Sudoku будет другой activity, именуемой Game и поэтому нам надо создать новый intent. Мы устанавливаем уровень сложности в extraData в intent, и после этого мы вызываем метод startActivity( ) для того чтобы запустить новую activity. extraData будет картой пар ключ/значение в intent. Ключи это строки, а значения могут быть примитивными типами, массивами примитивных типов, Bundle, и классами реализующими Serializable или Parcelable.
Объявление класса Game
Набросок activity выглядит так:
package org.example.sudoku; import android.app.Activity; import android.app.Dialog; import android.os.Bundle; import android.util.Log; import android.view.Gravity; import android.widget.Toast; public class Game extends Activity { private static final String TAG = "Sudoku" ; public static final String KEY_DIFFICULTY = "org.example.sudoku.difficulty" ; public static final int DIFFICULTY_EASY = 0; public static final int DIFFICULTY_MEDIUM = 1; public static final int DIFFICULTY_HARD = 2; private int puzzle[] = new int[9 * 9]; private PuzzleView puzzleView; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Log.d(TAG, "onCreate" ); int diff = getIntent().getIntExtra(KEY_DIFFICULTY, DIFFICULTY_EASY); puzzle = getPuzzle(diff); calculateUsedTiles(); puzzleView = new PuzzleView(this); setContentView(puzzleView); puzzleView.requestFocus(); } // ...
Метод onCreate() принимает параметр уровень сложности от intent и выбирает головоломку для игры. После этого оно создает экземпляр класса PuzzleView, устанавливая PuzzleView как новое содержание view. Это полностью измененный view, потому что его легче сделать в коде, а не в XML.
Метод calculateUsedTiles(), который не показан здесь, использует правила Sudoku, для каждой ячейки в таблице 9 на 9, чтобы выставить цифры.
Activity нужно зарегистрировать внутри AndroidManifest.xml:
Нам также нужно добавить немного ресурсов строк в res/values/strings.xml:
Game No moves Keypad
Как быть с размерами?
Общая ошибка совершаемая новыми разработчиками Android – использование определения ширины и высоты view в конструкторе. Когда вызывается конструктор вьевера, Android не имеет информации о том, будет ли вьевер занимать много или мало места, поэтому размеры устанавливаются в ноль. Реальные размеры высчитываются во время этапа layout, который происходит позже вызова конструктора, но перед тем, как вьевер будет отображон на экране. Вы можете использовать метод onSizeChanged(), который пересчитает значения, когда они уже известны, или вы можете использовать методы getWidth() и getHeight() позднее, например в методе onDraw().
Объявление класса PuzzleView
Затем нам нужно объявить класс PuzzleView. Вместо использования XML
layout, сделаем его в Java коде. Вот набросок:
package org.example.sudoku; import android.content.Context; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.Rect; import android.graphics.Paint.FontMetrics; import android.graphics.Paint.Style; import android.util.Log; import android.view.KeyEvent; import android.view.MotionEvent; import android.view.View; import android.view.animation.AnimationUtils; public class PuzzleView extends View { private static final String TAG = "Sudoku" ; private final Game game; public PuzzleView(Context context) { super(context); this.game = (Game) context; setFocusable(true); setFocusableInTouchMode(true); } // ... }
В конструкторе мы храним ссылку на класс Game и устанавливаем право пользователю ввод в вьевер. Внутри PuzzleView, нам потребуется метод onSizeChanged(). Он будет вызван после того как вьевер будет создан и Android знает про размеры.
private float width; // width of one tile private float height; // height of one tile private int selX; // X index of selection private int selY; // Y index of selection private final Rect selRect = new Rect(); @Overridе protected void onSizeChanged(int w, int h, int oldw, int oldh) { width = w / 9f; height = h / 9f; getRect(selX, selY, selRect); Log.d(TAG, "onSizeChanged: width " + width + ", height " + height); super.onSizeChanged(w, h, oldw, oldh); }
Мы используем onSizeChanged() для вычисления размера каждой ячейки на экране
(1/9-ая от полной ширины и высоты вьевера). Заметьте, это будет тип float, поэтому придется округлить его потом до целых, а объект selRect,- это прямоугольник, который мы будем использовать позднее, для отметки выбранной ячейки.
Мы создали view для головоломки, но мы узнали больше. Теперь нарисуем разделительные линии разделяющие ячейки на доске.
Другие пути реализации
Когда я писал этот пример, я попытался использовать некоторые другие подходы, такие как использование кнопок для каждой ячейки, или объявление решетки как класса ImageView в XML. После перебора, я нашел, что подход с одним вьевером для всей головоломки, обрисовывающий линии и цифры ячеек оказался самым быстрым и самым легким способом для этого приложения.
Конечно, он имеет свои недостатки, например, потребность перерисовывать выбранную ячейку и слежение за клавиатурой, событиями касания.
Конструируя свою собственную программу, я рекомендую попытаться обойтись стандартными контролами и после этого опускаться на уровень рисования.
Рисование доски
Android вызывает метод вьевера onDraw() каждый раз, когда любую часть вьевера
необходимо перерисовать. Проще говоря, onDraw() претендует на воссоздавание всего экрана. В реальности, вы можете перерисовывать малую часть вьевера, объявленную как прямоугольный холст(canvas’s).
Добавим новых цветов к игре внутри res/values/colors.xml:
#ffe6f0ff #ffffffff #64c6d4ef #6456648f #ff000000 #64ff0000 #6400ff80 #2000ff80 #64ff8000
Основной набросок для onDraw( ):
@Override protected void onDraw(Canvas canvas) { // Draw the background... Paint background = new Paint(); background.setColor(getResources().getColor( R.color.puzzle_background)); canvas.drawRect(0, 0, getWidth(), getHeight(), background); // Draw the board... // Draw the numbers... // Draw the hints... // Draw the selection... }
Единственный параметр это canvas, на котором будет рисование. В этом коде, мы устанавливаем фоновый цвет из ресурсов puzzle_background. Теперь давайте добавим код, для того чтобы нарисовать разделительные линии для доски:
// Draw the board... // Define colors for the grid lines Paint dark = new Paint(); dark.setColor(getResources().getColor(R.color.puzzle_dark)); Paint hilite = new Paint(); hilite.setColor(getResources().getColor(R.color.puzzle_hilite)); Paint light = new Paint(); light.setColor(getResources().getColor(R.color.puzzle_light)); // Draw the minor grid lines for (int i = 0; i < 9; i++) { canvas.drawLine(0, i * height, getWidth(), i * height, light); canvas.drawLine(0, i * height + 1, getWidth(), i * height + 1, hilite); canvas.drawLine(i * width, 0, i * width, getHeight(), light); canvas.drawLine(i * width + 1, 0, i * width + 1, getHeight(), hilite); } // Draw the major grid lines for (int i = 0; i < 9; i++) { if (i % 3 != 0) continue; canvas.drawLine(0, i * height, getWidth(), i * height, dark); canvas.drawLine(0, i * height + 1, getWidth(), i * height + 1, hilite); canvas.drawLine(i * width, 0, i * width, getHeight(), dark); canvas.drawLine(i * width + 1, 0, i * width + 1, getHeight(), hilite); }
Код использует 3 разных цвета для разделительных линий: светлый цвет между ячейками, темный цвет между блоками три на три и очень светлый цвет для обозначения краев каждой ячейки. Вы можете увидеть как это выглядит на рисунке 4.3.
Затем, нам нужно нарисовать цифры внутри этих линий.Рисунок 4.3. Рисование разделительных линий 3-мя цветами
Рисование цифр
Следующий код рисует цифру головоломки поверх ячейки. Каверзная часть здесь, это получение каждой цифры после определение расположения и после того как она будет отмасштабирована точно в центре своей ячейки.
// Draw the numbers... // Define color and style for numbers Paint foreground = new Paint(Paint.ANTI_ALIAS_FLAG); foreground.setColor(getResources().getColor( R.color.puzzle_foreground)); foreground.setStyle(Style.FILL); foreground.setTextSize(height * 0.75f); foreground.setTextScaleX(width / height); foreground.setTextAlign(Paint.Align.CENTER); // Draw the number in the center of the tile FontMetrics fm = foreground.getFontMetrics(); // Centering in X: use alignment (and X at midpoint) float x = width / 2; // Centering in Y: measure ascent/descent first float y = height / 2 - (fm.ascent + fm.descent) / 2; for (int i = 0; i < 9; i++) { for (int j = 0; j < 9; j++) { canvas.drawText(this.game.getTileString(i, j), i * width + x, j * height + y, foreground); } }
Рисунок 4.4. Отцентровка цифр внутри ячеек
Для того чтобы высчитать размер цифр, мы установим высоту три четверти от высоты ячейки и установим коэффициент сжатия такой же как и коэффициент сжатия ячейки. Мы не можем использовать абсолютные пиксели или пункты, потому что мы хотим, чтобы программа работала на любом разрешении.
Для того чтобы означить положение каждой цифры, мы центруем её в обоих координатах и x и y. По координате x это сделать легко,- надо просто разделить ширину ячейки на 2. Но для координаты y, мы должны отцентровать начальное положение центра ячейки со средней точкой цифры. Для этого мы используем класс из графической библиотеки FontMetrics, для того чтобы узнать какова высота цифры, и только после этого мы разделим её пополам для того чтобы получить регулировку. Вы можете увидеть
результаты на рисунке 4.4. Так мы обеспечим показ цифр головоломки в начале игры.
Рисунок 4.5. Рисование и перемещение курсора
4.3 Ручной ввод
Есть большая разница в программировании под Android в отличии от программирования под iPhone, - Android телефоны имеют разные размеры и формы, а так же методы ввода. Они могут иметь клавиатуру, D-pad, сенсорный экран, трекбол, или даже некоторую их комбинацию. Хорошая Android программа, должны быть готова поддержать всё оборудование телефона для ввода, а так же поддержать любые разрешения экрана.
Курсор для выбора и изменения значений в ячейках
Во-первых мы снабдим игрока курсором, который будет показывать, какая из ячеек в настоящее время выбрана. Эта одна выбранная ячейка будет модифицироваться, когда игрок нажмет на цифру. Добавим код внутрь метода onDraw( ):
// Draw the selection... Log.d(TAG, "selRect=" + selRect); Paint selected = new Paint(); selected.setColor(getResources().getColor( R.color.puzzle_selected)); canvas.drawRect(selRect, selected);
Мы будем использовать прямоугольник для курсора, который был вычислен в самом начале в методе onSizeChanged(), прямоугольник будем рисовать полупрозрачным цветом поверху выбранной ячейки. Затем мы обеспечим его движение путем перезагрузки метода onKey-Down( ):
@Override public boolean onKeyDown(int keyCode, KeyEvent event) { Log.d(TAG, "onKeyDown: keycode=" + keyCode + ", event=" + event); switch (keyCode) { case KeyEvent.KEYCODE_DPAD_UP: select(selX, selY - 1); break; case KeyEvent.KEYCODE_DPAD_DOWN: select(selX, selY + 1); break; case KeyEvent.KEYCODE_DPAD_LEFT: select(selX - 1, selY); break; case KeyEvent.KEYCODE_DPAD_RIGHT: select(selX + 1, selY); break; default: return super.onKeyDown(keyCode, event); } return true; }
Если устройство имеет D-пад, и пользователь будет нажимать вверх, вниз, вправо, влево, он вызовет метод select( ) для перемещения курсора в нужном направлении. Как же быть с трекболом? Мы можем переопределить метод onTrackballEvent(), но если обработки событий трекбола нет, Android будет переводить их в события D-пада автоматически. Поэтому, мы можем опустить переопределения onTrackballEvent() для этого примера.
Внутри метода select( ), мы высчитываем новую координату x и y для курсора и после этого используем метод getRect() для вычисления нового курсора:
private void select(int x, int y) { invalidate(selRect); selX = Math.min(Math.max(x, 0), 8); selY = Math.min(Math.max(y, 0), 8); getRect(selX, selY, selRect); invalidate(selRect); } private void getRect(int x, int y, Rect rect) { rect.set((int) (x * width), (int) (y * height), (int) (x * width + width), (int) (y * height + height)); }
Заметьте два обращение к методу invalidate( ). Первое сообщает Android о необходимости перерисовывания места старого прямоугольника (на левой стороне рисунка 4.5). Второе обращение к invalidate() необходимо для перерисовки нового положения курсора. Мы фактически тут ничего не рисуем.
Это важный момент: никогда не вызывайте никаких рисующих функций вне метода onDraw(). Вместо этого, используете метод invalidate(). Менеджер окон запомнит испорченный прямоугольник на экране и в будующем в методе onDraw( ) перерисует его для вас. Устаревшие прямоугольники (ячейки) появляются только в месте нажатия, поэтому они и будут перерисовываться, это сделано для оптимизации.
Теперь обеспечим игроку возможность записи новой цифры для выбранной ячейки.
Ввод цифр
Для того чтобы обработать события от клавиатуры, мы добавим больше возможных вариантов в методе onKey-Down( ), для перехвата нажатия на 0 до 9 (0 для стирания).
case KeyEvent.KEYCODE_0: case KeyEvent.KEYCODE_SPACE: setSelectedTile(0); break; case KeyEvent.KEYCODE_1: case KeyEvent.KEYCODE_2: case KeyEvent.KEYCODE_3: case KeyEvent.KEYCODE_4: case KeyEvent.KEYCODE_5: case KeyEvent.KEYCODE_6: case KeyEvent.KEYCODE_7: case KeyEvent.KEYCODE_8: case KeyEvent.KEYCODE_9: case KeyEvent.KEYCODE_ENTER: setSelectedTile(1); break; setSelectedTile(2); break; setSelectedTile(3); break; setSelectedTile(4); break; setSelectedTile(5); break; setSelectedTile(6); break; setSelectedTile(7); break; setSelectedTile(8); break; setSelectedTile(9); break; case KeyEvent.KEYCODE_DPAD_CENTER: game.showKeypadOrError(selX, selY); break;
Для того чтобы поддержать D-пад, мы добавим обработчик для нажатия на его центр в методе onKeyDown() для ввода цифр.
Ручной ввод
Раньше для этого примера, я перерисовывал весь экран, когда курсор была сдвинут. Таким образом, на каждом шаге, вся головоломка была перерисована. Это вызывало задержку. Перепись кода для того чтобы перерисовка затрагивала только необходимого прямоугольника удалило эту задержку.
Для касаний, мы переопределим метод onTouchEvent() и передадим координаты в метод их обработки, который объявим позже:
@Override public boolean onTouchEvent(MotionEvent event) { if (event.getAction() != MotionEvent.ACTION_DOWN) return super.onTouchEvent(event); select((int) (event.getX() / width), (int) (event.getY() / height)); game.showKeypadOrError(selX, selY); Log.d(TAG, "onTouchEvent: x " + selX + ", y " + selY); return true; }
В конечном счете, все пути идут к методу setSelectedTile( ), для изменения содержимого ячейки.
public void setSelectedTile(int tile) { if (game.setTileIfValid(selX, selY, tile)) { invalidate();// may change hints } else { // Number is not valid for this tile Log.d(TAG, "setSelectedTile: invalid: " + tile); } }
Заметьте, обращение к invalidate() происходит без параметров. То есть весь экран отмечается грязным, это нарушает ваши рассуждения ранее! Однако, в этом случай, это обязательно, потому что любые новые добавленные цифры меняют возможные подсказки игры, которые мы сделаем в следующем разделе.
Добавление подсказок
Как мы можем помочь игроку, а не сделать очередной шаг к решению головоломки? Мы раскрасим каждую ячейку в зависимости от количества возможных шагов. Добавьте к методу onDraw( ):
// Draw the hints... // Pick a hint color based on #moves left Paint hint = new Paint(); int c[] = { getResources().getColor(R.color.puzzle_hint_0), getResources().getColor(R.color.puzzle_hint_1), getResources().getColor(R.color.puzzle_hint_2), }; Rect r = new Rect(); for (int i = 0; i < 9; i++) { for (int j = 0; j < 9; j++) { int movesleft = 9 - game.getUsedTiles(i, j).length; if (movesleft < c.length) { getRect(i, j, r); hint.setColor(c[movesleft]); canvas.drawRect(r, hint); } } }
Мы имеем 3 случая: ноль, одна и два возможных шагов. Если будет ноль шагов, это значит, что игрок сделал что-то неправильно и нужно вернуться назад.
Результат можете посмотреть на рисунке 4.6. Можете вы определить ошибки совершенные игроком?[2]
Некоторые соображения
Если игрок пытается ввести очевидно неправильную цифру, например ту, которая уже была в блоке 3на3? Для развлечения, попрепятствуем игроку сделать это. Во-первых мы добавим обработчик для проверки введенной цифры внутрь метода setSelectedTile( ):
// Number is not valid for this tile Log.d(TAG, "setSelectedTile: invalid: " + tile); startAnimation(AnimationUtils.loadAnimation(game, R.anim.shake));
Рисунок 4.6. Выделены ячейки по количеству возможных вариантов в них.
Этот код загружает и запускает ресурс R.anim.shake, объявленный внутри res/anim/
shake.xml, и трясет экран 1.000 миллисекунд (1 секунду) на 10 пикселов от стороны к стороне(файл Sudokuv2/res/anim/shake.xml).
Время анимации и её скорость объявлено в XML.
Файл Sudokuv2/res/anim/cycle_7.xml:
Это определения количество повторов анимации.
4.4. Остальные нюансы
Теперь вернемся назад и свяжем в едино все ранее оговоренное, начнем с класса Д-пада. Эта часть необходима для программ, но не имеет отношения к графике. Вы можете свободно пропустить этот пункт и перейти к следующему: Создание улучшений.
Создание Keypad
Keypad присущь для телефонов не имеющих клавиатуры. Он показывает решетку из цифр от 1 до 9 в активити, которая появляется поверх. Весь её смысл сводиться к возвращению цифры, выбранной игроком.
Приведем разметку интерфейса в res/layout/keypad.xml:
Следующий шаг, объявление класса Keypad. Набросок в файле Keypad.java:
package org.example.sudoku; import android.app.Dialog; import android.content.Context; import android.os.Bundle; import android.view.KeyEvent; import android.view.View; public class Keypad extends Dialog { protected static final String TAG = "Sudoku"; private final View keys[] = new View[9]; private View keypad; private final int useds[]; private final PuzzleView puzzleView; public Keypad(Context context, int useds[], PuzzleView puzzleView) { super(context); this.useds = useds; this.puzzleView = puzzleView; } @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.keypad); findViews(); for (int element : useds) { if (element != 0) keys[element - 1].setVisibility(View.INVISIBLE); } setListeners(); }
Рисунок 4.7. Неправильные значения спрятаны во вьювере keypad.
Если определенная цифра неверна, то (например, такая цифра уже есть в ряде), тогда мы делаем цифру невидимой внутри решетки активити, поэтому игрок не сможет выбрать его (см. рисунок 4.7).
Метод findViews() извлекает и сохраняет идентификаторы вьеверов для всего keypad в главном окне keypad:
private void findViews() { keypad = findViewById(R.id.keypad); keys[0] = findViewById(R.id.keypad_1); keys[1] = findViewById(R.id.keypad_2); keys[2] = findViewById(R.id.keypad_3); keys[3] = findViewById(R.id.keypad_4); keys[4] = findViewById(R.id.keypad_5); keys[5] = findViewById(R.id.keypad_6); keys[6] = findViewById(R.id.keypad_7); keys[7] = findViewById(R.id.keypad_8); keys[8] = findViewById(R.id.keypad_9); }
Метод setListeners() закрепляет обработчики для всех идентификаторов keypad. Он также устанавливает обработчика на прием от главного окна keypad:
private void setListeners() { for (int i = 0; i < keys.length; i++) { final int t = i + 1; keys[i].setOnClickListener(new View.OnClickListener(){ public void onClick(View v) { returnResult(t); }}); } keypad.setOnClickListener(new View.OnClickListener(){ public void onClick(View v) { returnResult(0); }}); }
Когда игрок выбирает одну из кнопок на keypad, он вызывает метод returnResult() с номером этой кнопки. Если игрок выбирает другое место, а не кнопку, returnResult() вызывается с нолем, заставляя ячейку очиститься. onKeyDown() вызывается, когда игрок использует клавиатуру для того чтобы вписать цифру:
@Override public boolean onKeyDown(int keyCode, KeyEvent event) { int tile = 0; switch (keyCode) { case KeyEvent.KEYCODE_0: case KeyEvent.KEYCODE_SPACE: tile = 0; break; case KeyEvent.KEYCODE_1: case KeyEvent.KEYCODE_2: case KeyEvent.KEYCODE_3: case KeyEvent.KEYCODE_4: case KeyEvent.KEYCODE_5: case KeyEvent.KEYCODE_6: case KeyEvent.KEYCODE_7: case KeyEvent.KEYCODE_8: case KeyEvent.KEYCODE_9: default: tile = 1; break; tile = 2; break; tile = 3; break; tile = 4; break; tile = 5; break; tile = 6; break; tile = 7; break; tile = 8; break; tile = 9; break; } return super.onKeyDown(keyCode, event); } if (isValid(tile)) { returnResult(tile); } return true; }
Если цифра подходит для текущей ячейки в настоящее время, то оно вызывает returnResult( ); в противном случае, нажатие игнорируется.
Метод isValid() проверяет видимость цифр для текущей ячейки:
private boolean isValid(int tile) { for (int t : useds) { if (tile == t) return false; } return true; }
Если она имеется в массиве, то она не подходит, потому что эта цифра уже использовалась в настоящее время в ряде, колонке или блоке.
Метод returnResult() вызывается, для того чтобы возвратить выбранную цифру
вызывающей деятельности:
/** Return the chosen tile to the caller */ private void returnResult(int tile) { puzzleView.setSelectedTile(tile); dismiss(); }
Мы вызываем метод PuzzleView.setSelectedTile, для того чтобы изменить текущее значение ячейки, он же закрывает диалоговое окно Keypad.
Сейчас мы имеем активити, давайте вызовем её в классе игры() и получим результат:
/** Open the keypad if there are any valid moves */ protected void showKeypadOrError(int x, int y) { int tiles[] = getUsedTiles(x, y); if (tiles.length == 9) { Toast toast = Toast.makeText(this, R.string.no_moves_label, Toast.LENGTH_SHORT); toast.setGravity(Gravity.CENTER, 0, 0); toast.show(); } else { Log.d(TAG, "showKeypad: used=" + toPuzzleString(tiles)); Dialog v = new Keypad(this, tiles, puzzleView); v.show(); } }
Для того чтобы решить, какие цифры претендуют на правильность, мы помещаем в Keypad строку внутрь зоны extraData, которая содержит все номера, которые уже использованы.
Обеспечение логики игры
Остальной код внутри Game.java заботиться о логике игры, в частности контролирует шаги согласно правилам. Метод setTileIfValid() тут ключевой. Для него важны координаты x и y и новое значение ячейки и он изменяет ячейку только если значение верное.
/** Change the tile only if it's a valid move */ protected boolean setTileIfValid(int x, int y, int value) { int tiles[] = getUsedTiles(x, y); if (value != 0) { for (int tile : tiles) { if (tile == value) return false; } } setTile(x, y, value); calculateUsedTiles(); return true; }
Для того чтобы обнаружить правильное движение, мы создадим массив для каждой ячейки в решетке. Для каждого положения, оно будет содержать перечень заполненных и видимых в настоящее время ячеек. Если цифра уже есть в этом массиве, то она неверна для данной ячейке.
Метод getUsedTiles() возвращает массив для плитки, которую игрок выбрал:
/** Cache of used tiles */ private final int used[][] = new int[9][9]; /** Return cached used tiles visible from the given coords */ protected int[] getUsedTiles(int x, int y) { return used[x][y]; }
Массивы для используемых ячеек сложно вычисляются, поэтому мы его спрячем в КЭШе, и будем рассчитывает аждый раз, когда будет вызываться метод calculateUsedTiles( ):
/** Compute the two dimensional array of used tiles */ private void calculateUsedTiles() { for (int x = 0; x < 9; x++) { for (int y = 0; y < 9; y++) { used[x][y] = calculateUsedTiles(x, y); // Log.d(TAG, "used[" + x + "][" + y + "] = " // + toPuzzleString(used[x][y])); } } }
Метод calculateUsedTiles() просто вызывает метод calculateUsedTiles (x, y) для каждой из ячеек в решетке 9 на 9:
/** Compute the used tiles visible from this position */ private int[] calculateUsedTiles(int x, int y) { int c[] = new int[9]; // horizontal for (int i = 0; i < 9; i++) { if (i == y) continue; int t = getTile(x, i); if (t != 0) c[t - 1] = t; } // vertical for (int i = 0; i < 9; i++) { if (i == x) continue; int t = getTile(i, y); } if (t != 0) c[t - 1] = t; // same cell block int startx = (x / 3) * 3; int starty = (y / 3) * 3; for (int i = startx; i < startx + 3; i++) { for (int j = starty; j < starty + 3; j++) { if (i == x && j == y) continue; int t = getTile(i, j); if (t != 0) c[t - 1] = t; } } // compress int nused = 0; for (int t : c) { if (t != 0) nused++; } int c1[] = new int[nused]; nused = 0; for (int t : c) { if (t != 0) c1[nused++] = t; } return c1; }
Мы начинаем с массивом заполненным 9-ми нулями. Сначала, мы проверяем все ячейки в том же горизонтальном ряду, что и выбранная ячейка, и если она занята, то, мы заполняем массив этой цифрой. Далее, мы проделываем тоже самое, для всех ячеек в вертикальном ряду, а потом и для ячеек в блоке 3 на 3.
И последнее, необходимо выкинуть нули из массива, прежде чем он будет возвращен. Мы делаем это для того, чтобы array.length могло быть использовано для того, чтобы узнать, сколько ячеек уже используются вокруг текущей.
Разное
Добавим несколько других функций и переменных для реализации. easyPuzzle, mediumPuzzle, и hardPuzzle объявлены для легкого, среднего, и трудного уровня сложности, соответственно.
private final String easyPuzzle = "360000000004230800000004200" + "070460003820000014500013020" + "001900000007048300000000045"; private final String mediumPuzzle = "650000070000506000014000005" + "007009000002314700000700800" + "500000630000201000030000097"; private final String hardPuzzle = "009000000080605020501078000" + "000000700706040102004000000" + "000720903090301080000000600";
Метод getPuzzle() просто возвращает уровень сложности и составляет головоломку:
private int[] getPuzzle(int diff) { String puz; // TODO: Continue last game switch (diff) { case DIFFICULTY_HARD: puz = hardPuzzle; break; case DIFFICULTY_MEDIUM: puz = mediumPuzzle; break; case DIFFICULTY_EASY: default: puz = easyPuzzle; break; } return fromPuzzleString(puz); }
Позднее, мы изменим функцию getPuzzle() для обеспечения продолжения игры.
Метод toPuzzleString() преобразовывает головоломку из массива целых в строку. Метод
fromPuzzleString() выполняет преобразования наоборот.
/** Convert an array into a puzzle string */ static private String toPuzzleString(int[] puz) { StringBuilder buf = new StringBuilder(); for (int element : puz) { buf.append(element); } return buf.toString(); } /** Convert a puzzle string into an array */ static protected int[] fromPuzzleString(String string) { int[] puz = new int[string.length()]; for (int i = 0; i < puz.length; i++) { puz[i] = string.charAt(i) - '0'; } return puz; }
Метод getTile() принимает координаты x и y и возвращает цифру в текущей ячейке. Если там ноль,- ячейка пустая.
/** Return the tile at the given coordinates */ private int getTile(int x, int y) { return puzzle[y * 9 + x]; } /** Change the tile at the given coordinates */ private void setTile(int x, int y, int value) { puzzle[y * 9 + x] = value; }
Метод getTileString() используется для отображения ячейки. Оно возвращает либо строку с значением ячейки, либо пустую строку, если ячейка пустая.
/** Return a string for the tile at the given coordinates */ protected String getTileString(int x, int y) { int v = getTile(x, y); if (v == 0) return "" ; else return String.valueOf(v); }
Как только все эти части определены, вы должны иметь играбельную Sudoku. Проврете её работу. Как и с любым кодом, тут есть простор для улучшений.
4.5 Улучшения
Хотя листинги представленные в этой главе вполне приемлемы для игры Sudoku, более сложным программам вероятно надо будет тщательнее все оптимизировать, чтобы они не падали из-за FC. В частности, метод onDraw() является очень критичным для активити, поэтому его необходимо использовать оптимально:
- По возможности, избегайте создание объектов в методе onDraw( ).
- Такие вещи, как константы цвета, объявляйте в другом месте (например, внутри конструктора вьевера).
- Создавайте объекты Paint заранее и потом их используйте в onDraw( ).
Для значений используемых несколько раз, например, ширина возвращенная методом getWidth(), лучше получать значение в начале метода и после этого его копировать в локальную переменную, чтобы не вызывать метод снова.
Для тренировки, я советую вам подумать о том, как вы могли бы сделать игру Sudoku графически более наполненной. Например, вы в состоянии добавить код для обозначения ячеек, движущейся задний фон позади головоломки тоже интересен, словом проявите фантазию.
В главе 5, Мультимедия, мы добавим в программу музыку, а в главе 6, Хранение данных, мы узнаем, как сохранить или загрузить положение головоломки и добавим кнопку Продолжить.
4.6 Резюме
В этой главе, мы ознакомились с графическими возможностями Android. Нативная библиотека 2D довольно большая, таким образом, если вы писали свою программу сами, вы осознали преимущества редактора кода, автозавершения и Javadoc, которые обеспечиваются плагином Android для Eclipse.
Онлайн документация для пакета android.graphics[3], более подробная. Если в вашей программе необходима более сложная графика, то вы можете посмотреть главу 10, 3D Графика в OpenGL. Там вы будете овладевать знаниями для использования 3D-графики в Android, которая основана на стандарте OpenGL ES. В противном случае перейдите к следующей главе и ознакомьтесь с чудесным миром аудио и видео в Android.
[1] Функциональность для четырехмерной графики была бы рассмотрена для Android, но она была опущена из-за отсутсвия времени.
[2] Две цифры на нижнем ряде и среднем блоке, неправильны.
[3] http://d.android.com/reference/android/graphics/package-summary.html
Комментариев нет:
Отправить комментарий