Сегодня мы будем писать фреймворк с названием Mechanic Framework для удобной разработки игр под андроид.
Что нам потребуется:
- Установленные Eclipse и Android SDK
- Приличное знание Java либо другого С-подобного языка. Лучший пример – C#
- Терпение
Для начала создаем проект.
File – New – Other – Android Application Project
Появляется окошко New Android Application. Вводим любое имя (например, Mechanic), называем package своим именем, выбираем минимально возможную версию андроид для приложения и целевую версию, нажимаем Next.
Нажимаем Next.
Выбираем иконку (если вам не нравится иконка андроида, жмите Clipart – Choose и выбираем что-нибудь, либо ставим свою иконку).
Жмем Next.
Выбираем название для Activity, например, MyGame, жмем Finish.
Откроется .xml окно визуального редактирования, закрываем его.
Открываем AndroidManifest.xml и настраиваем его под свои нужды
Для того, чтобы устанавливать игру на карту памяти, когда это возможно, и не загрязнять внутреннюю память устройства, в поле manifest пишем
android:installLocation="preferExternal"
Для того, чтобы приложение было доступным для отладки, пишем в поле application
android:debuggable="true"
Для того, чтобы приложение было зафиксировано в портретном либо ландшафтном режиме (в этом случае ландшафтный режим), в поле activity пишем
android:screenOrientation="landscape"
Для того, чтобы приложение на эмуляторе могло обрабатывать действия с клавиатурой, пишем в том же поле
android:configChanges="keyboard|keyboardHidden|orientation"
Когда вы скачиваете приложение с Google Play, вы замечаете, что приложения требуют доступа к карте памяти/к интернету и прочим вещам, так вот, для того, чтобы получить контроль над картой памяти и предотвратить блокировку экрана при бездействии, пишем
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.WAKE_LOCK"/>
Вид манифеста будет примерно такой
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.frame" android:versionCode="1" android:versionName="1.0" android:installLocation="preferExternal"> <uses-sdk android:minSdkVersion="8" android:targetSdkVersion="18" /> <application android:allowBackup="true" android:icon="@drawable/ic_launcher" android:label="@string/app_name" android:theme="@style/AppTheme" android:debuggable="true" > <activity android:name="com.frame.MyGame" android:screenOrientation="landscape" android:configChanges="keyboard|keyboardHidden|orientation" android:label="@string/app_name" > <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> </application> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.WAKE_LOCK"/> </manifest>
Закрываем манифест
Теперь необходимо создать каркас фреймворка – интерфейсы, управляющие вводом, отрисовкой графики и прочим, а позже все интерфейсы реализовать.
Ввод
Создаем новый package с названием com.mechanic.input
Создаем интерфейс Input в этом package, и доводим его до такого вида
public interface Input { public static class MechanicKeyEvent { public static final int KEY_DOWN = 0, KEY_UP = 1; public int Type; public int KeyCode; public char KeyChar; } public static class MechanicTouchEvent { public static final int TOUCH_DOWN = 0, TOUCH_UP = 1, TOUCH_DRAGGED = 2; public int Type; public int X, Y; public int Pointer; } public boolean IsKeyPressed(int KeyCode); public boolean IsKeyPressed(char KeyChar); public boolean IsTouchDown(int pointer); public int GetTouchX(int pointer); public int GetTouchY(int pointer); public float GetAccelX(); public float GetAccelY(); public float GetAccelZ(); public List<MechanicTouchEvent> GetTouchEvents(); public List<MechanicKeyEvent> GetKeyEvents(); }
GetKeyDown – булево значение, принимает код клавиши и возвращает true, если нажата кнопка
GetTouchDown – булево значение, возвращает true, если нажат экран, причем принимает эта функция номер пальца, нажавшего экран. Старые версии андроида не поддерживает Multitouch.
GetTouchX – возвращает X-координату нажатой клавиши
GetTouchY – возвращает Y-координату нажатой клавиши
Обе последние функции принимают номер пальца
GetAccelX, GetAccelY, GetAccelZ – возвращают ускорение по какой-либо координате акселерометра. Когда мы держим телефон в портретном режиме вертикально вверх, то ускорение по оси Y будет равно 9.6 м/с2, по осям X и Z 0 м/с2.
Обратите внимание на MechanicKeyEvent и MechanicTouchEvent
Первый класс хранит информацию о событии клавиши. Type всегда будет либо KEY_DOWN либо KEY_UP. KeyCode и KeyChar хранят значение клавиши в числовом и символьном типе соответсвенно.
Во втором классе X и Y – координаты пальца, нажимающего экран, Pointer – номер пальца. TOUCH_DRAGGED означает перемещение пальца.
Стоит отвлечься и сказать о том, как налажен интерфейс Input.
За акселерометр, клавиатуру и нажатия на экран отвечает не тот класс, который реализует Input, а те классы, что будут реализовывать интерфейсы Accelerometer, Keyboard и Touch соответственно. Input будет просто хранить экземпляры этих классов. Если вы знакомы с паттернами проектирования, то должны знать, что таким образом реализуется нехитрый паттерн «Фасад».
Вот эти интерфейсы
public interface Accelerometer extends SensorEventListener { public float GetAccelX(); public float GetAccelY(); public float GetAccelZ(); }
public interface Keyboard extends OnKeyListener { public boolean IsKeyPressed(int keyCode); public List<KeyEvent> GetKeyEvents(); }
public interface Touch extends OnTouchListener { public boolean IsTouchDown(int pointer); public int GetTouchX(int pointer); public int GetTouchY(int pointer); public List<TouchEvent> GetTouchEvents(); }
Нетрудно догадаться, что Input просто перенаправляет методы в другие классы, а те работают честно и выкладывают результаты.
Файлы
Настало время работы с файлами. Наш интерфейс будет называться FileIO, так как класс File уже есть.
Создаем новый package com.mechanic.fileio и новый интерфейс в нем
public interface FileIO { public InputStream ReadAsset(String name) throws IOException; public InputStream ReadFile(String name) throws IOException; public OutputStream WriteFile(String name) throws IOException; }
Обычно мы храним все картинки, звуки и прочие файлы в папке assets проекта. Первая функция открывает файл с указанным именем из assets, позволяя избежать лишней мороки с AssetsManager. Последние 2 функции нужны, например, для сохранения рекордов. Когда мы сохраняем данные, то записываем в хранилище устройства текстовый файл с информацией, а потом считываем его. На всякий случай постарайтесь придумать название файла пооригинальнее «file.txt», например, «.mechanicsave» — так тоже можно.
Звуки
Создаем package com.mechanic.audio и новый интерфейс Audio
public interface Audio { public Music NewMusic(String name); public Sound NewSound(String name); }
У нас есть 2 варианта хранения и воспроизведения звука. Первый вариант – обычный, когда мы загружаем звук и проигрываем его, но такой подход в большинстве случаев годится для маленьких звуков вроде выстрелов и взрывов, а для больших звуковых файлов вроде фоновой музыки бессмысленно полностью загружать звук, поэтому мы используем в этом случае потоковое произведение звуков, динамически подгружая звуки и проигрывая их. За первый и за второй вариант отвечают соответственно интерфейсы Sound и Music. Вот их определения
public interface Sound { public void Play(float volume); public void Close(); }
public interface Music extends OnCompletionListener { public void Close(); public boolean IsLooping(); public boolean IsPlaying(); public boolean IsStopped(); public void Play(); public void SetLooping(boolean loop); public void SetVolume(float volume); public void Stop(); }
Графика
Создаем package com.mechanic.graphics
За графику отвечает в основном интерфейс Graphics
Вот его определение
public interface Graphics { public static enum ImageFormat { ARGB_8888, ARGB_4444, RGB_565 } public Image NewImage(String fileName); public void Clear(int color); public void DrawPixel(int x, int y, int color); public void DrawLine(int x, int y, int x2, int y2, int color); public void DrawRect(int x, int y, int width, int height, int color); public void DrawImage(Image image, int x, int y, int srcX, int srcY, int srcWidth, int srcHeight); public void DrawImage(Image image, int x, int y); public int GetWidth(); public int GetHeight(); }
ImageFormat – перечисление, облегчающее выбор способа загрузки изображения. Вообще-то он ничего особенного не делает, но перечисление, куда надо передавать формат, имеет еще кучу ненужных методов и ненужное название Config, так что пусть будет так.
NewImage возвращает новое изображение, мы его будет сохранять в переменной и рисовать
Методы с названиями Draw… говорят сами за себя, причем первый метод DrawImage рисует только часть изображения, а второй – изображение полностью.
GetWidth и GetHeight возвращают размер «полотна», где мы рисуем картинки
Есть еще один интерфейс – для картинок
public interface Image { public int GetWidth(); public int GetHeight(); public ImageFormat GetFormat(); public void Dispose(); }
Все достаточно красноречиво
Централизованное управление игрой
Создаем package com.mechanic.game
Остался предпоследний важный интерфейс, который будет поддерживать работу всего приложения – Game
public interface Game { public Input GetInput(); public FileIO GetFileIO(); public Graphics GetGraphics(); public Audio GetAudio(); public void SetScreen(Screen screen); public Screen GetCurrentScreen(); public Screen GetStartScreen(); }
Мы просто пихаем туда интерфесы – темы прошлых глав.
Но что такое Screen?
Позвольте отвлечься. Почти каждая игра состоит из нескольких «состояний» — главное меню, меню настроек, экран рекордов, все уровни и т.д. и т.п. Немудрено, что поддержка хотя бы 5 состояний может ввергнуть нас в пучину кода. Нас спасает абстрактный класс Screen
public abstract class Screen { protected final Game game; public Screen(Game game) { this.game = game; } public abstract void Update(float deltaTime); public abstract void Present(float deltaTime); public abstract void Pause(); public abstract void Resume(); public abstract void Dispose(); }
Каждый наследник Screen (MainMenuScreen, SettingsScreen) отвечает за такое «состояние». У него есть несколько функций.
Update – обновление
Present – показ графики (введено для удобства, на самом деле эта функция вызывается так же, как предыдущая)
Pause – вызывается каждый раз, когда игра ставится на паузу (блок экрана)
Resume – продолжение игры после паузы
Dispose – освобождение всех ресурсов, к примеру, загруженных картинок
Стоит немного рассказать об deltaTime, передающихся в 2 функции.
Более искушенным геймдевелоперам известна проблема, когда скорость игры (допустим, передвижение игрока) зависит напрямую от скорости устройства, т.е. если мы будем увеличивать переменную x на 1 каждый цикл, то никогда не будет такого, чтобы игра работала одинаково и на нетбуке, и на компе с огромной оперативкой.
Таким образом, труЪ-вариант:
@Override public void Update(float deltaTime) { x += 150 * deltaTime; }
Не труЪ-вариант:
@Override public void Update(float deltaTime) { x += 150; }
Есть одна элементарная ошибка – очень часто, увеличивая x на 1.0f*deltaTime, не всегда можно заметить, что сложение целого числа с нецелым числом от 0 до 1 не дает никакого результата, засим x должен быть float
Как мы будем сменять экраны? Возвратимся к интерфейсу Game
За все отвечает функция SetScreen. Также есть функции для получения текущего и стартового экрана.
Настало время реализовать весь этот сборник!
Начинаем с ввода
Вы заметили, что в интерфейсе Input есть функции GetKeyEvents и GetTouchEvents, которые возвращают список событий, то есть по случаю какого-либо события программа создает множество объектов, которые затем чистит сборщик мусора. Скажите мне, в чем главная причина тормозов приложений для андроид? Правильно – это перегружение сборщика мусора! Нам надо как-то проконтролировать проблему. Перед тем, как продолжить, создадим класс Pool, реализуем «object pooling», способ, предложенный в прекрасной книге Марио Цехнера «Программирование игр для Android».
Его смысл заключается в том, что мы не даем сборщику мусора мешать приложению и не тратим попусту нужные ресурсы
public class Pool<T> { public interface PoolFactory<T> { public T Create(); } private final List<T> Objects; private final PoolFactory<T> Factory; private final int MaxSize; public Pool(PoolFactory<T> Factory, int MaxSize) { this.Factory = Factory; this.MaxSize = MaxSize; Objects = new ArrayList<T>(MaxSize); } public T NewObject() { T obj = null; if (Objects.size() == 0) obj = Factory.Create(); else obj = Objects.remove(Objects.size() - 1); return obj; } public void Free(T object) { if (Objects.size() < MaxSize) Objects.add(object); } }
Допустим, у нас есть объект Pool pool. Вот так его используем
PoolFactory<MechanicTouchEvent> factory = new PoolFactory<MechanicTouchEvent>() { @Override public MechanicTouchEvent Create() { return new MechanicTouchEvent(); } }; TouchEventPool = new Pool<MechanicTouchEvent>(factory, 100);
Объявление пула
TouchEventPool.Free(event);
Сохранение события в пуле
event = TouchEventPool.NewObject();
Получаем событие из пула. Если список пуст, то это не страшно, так как после использования события мы его помещаем в пул обратно до следующего вызова.
Очень хорошая вещь!
MechanicAccelerometer
package com.mechanic.input; import android.content.Context; import android.hardware.Sensor; import android.hardware.SensorEvent; import android.hardware.SensorManager; public class MechanicAccelerometer implements Accelerometer { float accelX, accelY, accelZ; public MechanicAccelerometer(Context context) { SensorManager manager = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE); if(manager.getSensorList(Sensor.TYPE_ACCELEROMETER).size() > 0) { Sensor accelerometer = manager.getSensorList(Sensor.TYPE_ACCELEROMETER).get(0); manager.registerListener(this, accelerometer, SensorManager.SENSOR_DELAY_GAME); } } @Override public void onAccuracyChanged(Sensor sensor, int accuracy) { } @Override public void onSensorChanged(SensorEvent event) { accelX = event.values[0]; accelY = event.values[1]; accelZ = event.values[2]; } @Override public float GetAccelX() { return accelX; } @Override public float GetAccelY() { return accelY; } @Override public float GetAccelZ() { return accelZ; } }
Кроме Accelerometer, этот класс реализует еще SensorEventListener – он нужен для получения контроля не только над акселерометром, но и над прочими игрушками – компасом, фонариком, что-то еще. Пока что мы делаем только акселерометр.
В конструкторе мы получаем менеджер сенсоров и проверяем, есть ли доступ к акселерометру. Вообще теоретически акселерометров может быть не 1, а несколько (это же List, а не один объект), практически же он всегда один. Если число акселерометров больше 0, получаем первый из них и регистрируем его, выставляя этот класс в качестве listener’a (слушателя). onAccuracyChanged нужен, если сбилась точность сенсора, мы это не используем. onSensorChanged вызывается всегда, когда изменяется значение акселерометра, тут-то мы и снимаем показания.
MechanicTouch
package com.mechanic.input; import java.util.ArrayList; import java.util.List; import com.mechanic.input.Input.MechanicTouchEvent; import com.mechanic.input.Pool.PoolFactory; import android.os.Build.VERSION; import android.view.MotionEvent; import android.view.View; public class MechanicTouch implements Touch { boolean EnableMultiTouch; final int MaxTouchers = 20; boolean[] IsTouched = new boolean[MaxTouchers]; int[] TouchX = new int[MaxTouchers]; int[] TouchY = new int[MaxTouchers]; Pool<MechanicTouchEvent> TouchEventPool; List<MechanicTouchEvent> TouchEvents = new ArrayList<MechanicTouchEvent>(); List<MechanicTouchEvent> TouchEventsBuffer = new ArrayList<MechanicTouchEvent>(); float ScaleX; float ScaleY; public MechanicTouch(View view, float scaleX, float scaleY) { if(Integer.parseInt(VERSION.SDK) < 5) EnableMultiTouch = false; else EnableMultiTouch = true; PoolFactory<MechanicTouchEvent> factory = new PoolFactory<MechanicTouchEvent>() { @Override public MechanicTouchEvent Create() { return new MechanicTouchEvent(); } }; TouchEventPool = new Pool<MechanicTouchEvent>(factory, 100); view.setOnTouchListener(this); this.ScaleX = scaleX; this.ScaleY = scaleY; } @Override public boolean onTouch(View v, MotionEvent event) { synchronized (this) { int action = event.getAction() & MotionEvent.ACTION_MASK; @SuppressWarnings("deprecation") int pointerIndex = (event.getAction() & MotionEvent.ACTION_POINTER_ID_MASK) >> MotionEvent.ACTION_POINTER_ID_SHIFT; int pointerId = event.getPointerId(pointerIndex); MechanicTouchEvent TouchEvent; switch (action) { case MotionEvent.ACTION_DOWN: case MotionEvent.ACTION_POINTER_DOWN: TouchEvent = TouchEventPool.NewObject(); TouchEvent.Type = MechanicTouchEvent.TOUCH_DOWN; TouchEvent.Pointer = pointerId; TouchEvent.X = TouchX[pointerId] = (int)(event.getX(pointerIndex) * ScaleX); TouchEvent.Y = TouchY[pointerId] = (int)(event.getY(pointerIndex) * ScaleY); IsTouched[pointerId] = true; TouchEventsBuffer.add(TouchEvent); break; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_POINTER_UP: case MotionEvent.ACTION_CANCEL: TouchEvent = TouchEventPool.NewObject(); TouchEvent.Type = MechanicTouchEvent.TOUCH_UP; TouchEvent.Pointer = pointerId; TouchEvent.X = TouchX[pointerId] = (int)(event.getX(pointerIndex) * ScaleX); TouchEvent.Y = TouchY[pointerId] = (int)(event.getY(pointerIndex) * ScaleY); IsTouched[pointerId] = false; TouchEventsBuffer.add(TouchEvent); break; case MotionEvent.ACTION_MOVE: int pointerCount = event.getPointerCount(); for (int i = 0; i < pointerCount; i++) { pointerIndex = i; pointerId = event.getPointerId(pointerIndex); TouchEvent = TouchEventPool.NewObject(); TouchEvent.Type = MechanicTouchEvent.TOUCH_DRAGGED; TouchEvent.Pointer = pointerId; TouchEvent.X = TouchX[pointerId] = (int)(event.getX(pointerIndex) * ScaleX); TouchEvent.Y = TouchY[pointerId] = (int)(event.getY(pointerIndex) * ScaleY); TouchEventsBuffer.add(TouchEvent); } break; } return true; } } @Override public boolean IsTouchDown(int pointer) { synchronized(this) { if(pointer < 0 || pointer >= MaxTouchers) return false; else return IsTouched[pointer]; } } @Override public int GetTouchX(int pointer) { synchronized(this) { if (pointer < 0 || pointer >= MaxTouchers) return 0; else return TouchX[pointer]; } } @Override public int GetTouchY(int pointer) { synchronized(this) { if (pointer < 0 || pointer >= 20) return 0; else return TouchY[pointer]; } } @Override public List<MechanicTouchEvent> GetTouchEvents() { synchronized (this) { for (int i = 0; i < TouchEvents.size(); i++) TouchEventPool.Free(TouchEvents.get(i)); TouchEvents.clear(); TouchEvents.addAll(TouchEventsBuffer); TouchEventsBuffer.clear(); return TouchEvents; } } }
Кроме Touch мы реализуем еще OnTouchListener
EnableMultiTouch нужен для определения, поддерживает ли устройство одновременное нажатие нескольких пальцев. Если VERSION.SDK меньше 5 (представлена эта переменная почему-то в виде строки), то не поддерживает.
MaxTouchers – максимальное число пальцев. Их 20, может быть больше или меньше.
В функции onTouch мы получаем номер пальца и действие (нажатие, отрыв, перемещение), которое записываем в событие и добавляем событие в список.
В GetTouchEvents мы возвращаем список событий, который после этого очищаем. За возвращение списка событий отвечает другой список.
Вы можете спросить, за что отвечает ScaleX и ScaleY? Об этом будет рассказано чуть позже, в разделе графики
MechanicKeyboard
package com.mechanic.input; import java.util.ArrayList; import java.util.List; import android.view.KeyEvent; import android.view.View; import com.mechanic.input.Input.MechanicKeyEvent; import com.mechanic.input.Pool.PoolFactory; import com.mechanic.input.Pool; public class MechanicKeyboard implements Keyboard { boolean[] PressedKeys = new boolean[128]; Pool<MechanicKeyEvent> KeyEventPool; List<MechanicKeyEvent> KeyEventsBuffer = new ArrayList<MechanicKeyEvent>(); List<MechanicKeyEvent> KeyEvents = new ArrayList<MechanicKeyEvent>(); public MechanicKeyboard(View view) { PoolFactory<MechanicKeyEvent> pool = new PoolFactory<MechanicKeyEvent>() { @Override public MechanicKeyEvent Create() { return new MechanicKeyEvent(); } }; KeyEventPool = new Pool<MechanicKeyEvent>(pool,100); view.setOnKeyListener(this); view.setFocusableInTouchMode(true); view.requestFocus(); } public boolean IsKeyPressed(int KeyCode) { if(KeyCode < 0 || KeyCode > 127) return false; return PressedKeys[KeyCode]; } public List<MechanicKeyEvent> GetKeyEvents() { synchronized(this) { for(int i = 0; i < KeyEvents.size(); i++) KeyEventPool.Free(KeyEvents.get(i)); KeyEvents.clear(); KeyEvents.addAll(KeyEventsBuffer); KeyEventsBuffer.clear(); return KeyEvents; } } @Override public boolean onKey(View v, int keyCode, KeyEvent event) { if(event.getAction() == KeyEvent.ACTION_MULTIPLE) return false; synchronized(this) { MechanicKeyEvent key = KeyEventPool.NewObject(); key.KeyCode = keyCode; key.KeyChar = (char)event.getUnicodeChar(); if(event.getAction() == KeyEvent.ACTION_DOWN) { key.Type = MechanicKeyEvent.KEY_DOWN; if(keyCode > 0 && keyCode < 128) PressedKeys[keyCode] = true; } if(event.getAction() == KeyEvent.ACTION_UP) { key.Type = MechanicKeyEvent.KEY_UP; if(keyCode > 0 && keyCode < 128) PressedKeys[keyCode] = false; } KeyEventsBuffer.add(key); } return false; } }
Создаем массив из 128 булевых переменных, которые будут держать информацию о 128 нажатых или не нажатых клавишах. Также создаем пул объектов и 2 списка. Все просто
MechanicInput
package com.mechanic.input; import java.util.List; import android.content.Context; import android.view.View; public class MechanicInput implements Input { MechanicKeyboard keyboard; MechanicAccelerometer accel; MechanicTouch touch; public MechanicInput(Context context, View view, float scaleX, float scaleY) { accel = new MechanicAccelerometer(context); keyboard = new MechanicKeyboard(view); touch = new MechanicTouch(view, scaleX, scaleY); } @Override public boolean IsKeyPressed(int keyCode) { return keyboard.IsKeyPressed(keyCode); } @Override public boolean IsKeyPressed(char keyChar) { return keyboard.IsKeyPressed(keyChar); } @Override public boolean IsTouchDown(int pointer) { return touch.IsTouchDown(pointer); } @Override public int GetTouchX(int pointer) { return touch.GetTouchX(pointer); } @Override public int GetTouchY(int pointer) { return touch.GetTouchY(pointer); } @Override public float GetAccelX() { return accel.GetAccelX(); } @Override public float GetAccelY() { return accel.GetAccelY(); } @Override public float GetAccelZ() { return accel.GetAccelZ(); } @Override public List<MechanicTouchEvent> GetTouchEvents() { return touch.GetTouchEvents(); } @Override public List<MechanicKeyEvent> GetKeyEvents() { return keyboard.GetKeyEvents(); } }
Реализуем паттерн «Фасад».
Теперь настало время поработать с файлами!
Работа с файлами
MechanicFileIO
package com.mechanic.fileio; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import android.content.res.AssetManager; import android.os.Environment; public class MechanicFileIO implements FileIO { AssetManager assets; String ExternalStoragePath; public MechanicFileIO(AssetManager assets) { this.assets = assets; ExternalStoragePath = Environment.getExternalStorageDirectory().getAbsolutePath() + File.separator; } public InputStream ReadAsset(String name) throws IOException { return assets.open(name); } public InputStream ReadFile(String name) throws IOException { return new FileInputStream(ExternalStoragePath + name); } public OutputStream WriteFile(String name) throws IOException { return new FileOutputStream(ExternalStoragePath + name); } }
Мы получаем менеджер ассетов для изъятия файлов из папки assets, его использует первая функция, а вторые 2 функции берут файлы из специальной папки устройства на андроид, куда записываем и откуда считываем все данные насчет игры – рекорды, настройки, и прочее. Путь до этой папки берем в конструкторе.
Теперь создаем звуки
Работа со звуками
MechanicSound
package com.mechanic.audio; import android.media.SoundPool; public class MechanicSound implements Sound { int id; SoundPool pool; public MechanicSound(SoundPool pool, int id) { this.pool = pool; this.id = id; } public void Play(float volume) { pool.play(id, volume, volume, 0, 0, 1); } public void Close() { pool.unload(id); } }
В MechanicAudio для держания мелких звуковых эффектов мы используем SoundPool. В MechanicSound мы передаем номер звукового эффекта и сам объект SoundPool, от которого производим звук
MechanicMusic
package com.mechanic.audio; import java.io.IOException; import android.content.res.AssetFileDescriptor; import android.media.MediaPlayer; public class MechanicMusic implements Music { MediaPlayer Player; boolean IsPrepared = false; public MechanicMusic(AssetFileDescriptor descriptor) { Player = new MediaPlayer(); try { Player.setDataSource(descriptor.getFileDescriptor(), descriptor.getStartOffset(), descriptor.getLength()); Player.prepare(); IsPrepared = true; } catch(Exception ex) { throw new RuntimeException("Невозможно загрузить потоковую музыку"); } } public void Close() { if(Player.isPlaying()) Player.stop(); Player.release(); } public boolean IsLooping() { return Player.isLooping(); } public boolean IsPlaying() { return Player.isPlaying(); } public boolean IsStopped() { return !IsPrepared; } public void Play() { if(Player.isPlaying()) return; try { synchronized(this) { if(!IsPrepared) Player.prepare(); Player.start(); } } catch(IllegalStateException ex) { ex.printStackTrace(); } catch(IOException ex) { ex.printStackTrace(); } } public void SetLooping(boolean loop) { Player.setLooping(loop); } public void SetVolume(float volume) { Player.setVolume(volume, volume); } public void Stop() { Player.stop(); synchronized(this) { IsPrepared = false; } } @Override public void onCompletion(MediaPlayer player) { synchronized(this) { IsPrepared = false; } } }
Мы ставим звуковой файл на поток и воспроизводим его.
IsPrepared показывает, готов ли звук для произведения.
Рекомендую самому разобраться в этом классе.
Мы дошли до MechanicAudio
package com.mechanic.audio; import java.io.IOException; import android.app.Activity; import android.content.res.AssetFileDescriptor; import android.content.res.AssetManager; import android.media.AudioManager; import android.media.SoundPool; public class MechanicAudio implements Audio { AssetManager assets; SoundPool pool; public MechanicAudio(Activity activity) { activity.setVolumeControlStream(AudioManager.STREAM_MUSIC); this.assets = activity.getAssets(); pool = new SoundPool(20, AudioManager.STREAM_MUSIC, 0); } public Music NewMusic(String name) { try { AssetFileDescriptor descriptor = assets.openFd(name); return new MechanicMusic(descriptor); } catch(IOException ex) { throw new RuntimeException("Невозможно загрузить потоковую музыку " + name); } } public Sound NewSound(String name) { try { AssetFileDescriptor descriptor = assets.openFd(name); int id = pool.load(descriptor, 0); return new MechanicSound(pool, id); } catch(IOException ex) { throw new RuntimeException("Невозможно загрузить звуковой эффект " + name); } } }
В конструкторе мы делаем возможность регулировать музыку устройством, берем менеджер ассетов и создаем SoundPool, который может проигрывать не более 20 звуковых эффектов за раз. Думаю, в большинстве игр этого хватит.
В создании Music мы передаем в конструктор MechanicMusic дескриптор файла, в создании Sound загружаем звук в soundPool и передаем в конструктор MechanicSound сам пул и номер звука, если что-то идет не так, делается исключение.
Делаем рисовальщик
Работа с графикой
MechanicImage
package com.mechanic.graphics; import com.mechanic.graphics.Graphics.ImageFormat; import android.graphics.Bitmap; public class MechanicImage implements Image { Bitmap bitmap; ImageFormat format; public MechanicImage(Bitmap bitmap, ImageFormat format) { this.bitmap = bitmap; this.format = format; } @Override public int GetWidth() { return bitmap.getWidth(); } @Override public int GetHeight() { return bitmap.getHeight(); } @Override public ImageFormat GetFormat() { return format; } @Override public void Dispose() { bitmap.recycle(); } }
Этот класс – держатель изображения. Ничего особенного он не делает, введен для удобства.
MechanicGraphics
package com.mechanic.graphics; import java.io.IOException; import java.io.InputStream; import android.content.res.AssetManager; import android.graphics.Bitmap; import android.graphics.Bitmap.Config; import android.graphics.BitmapFactory; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.Paint.Style; import android.graphics.Rect; public class MechanicGraphics implements Graphics { AssetManager assets; Bitmap buffer; Canvas canvas; Paint paint; Rect srcRect = new Rect(), dstRect = new Rect(); public MechanicGraphics(AssetManager assets, Bitmap buffer) { this.assets = assets; this.buffer = buffer; this.canvas = new Canvas(buffer); this.paint = new Paint(); } @Override public Image NewImage(String fileName) { ImageFormat format; InputStream file = null; Bitmap bitmap = null; try { file = assets.open(fileName); bitmap = BitmapFactory.decodeStream(file); if (bitmap == null) throw new RuntimeException("Нельзя загрузить изображение '" + fileName + "'"); } catch (IOException e) { throw new RuntimeException("Нельзя загрузить изображение '" + fileName + "'"); } finally { try { if(file != null) file.close(); } catch(IOException e) { } } if (bitmap.getConfig() == Config.RGB_565) format = ImageFormat.RGB_565; else if (bitmap.getConfig() == Config.ARGB_4444) format = ImageFormat.ARGB_4444; else format = ImageFormat.ARGB_8888; return new MechanicImage(bitmap, format); } @Override public void Clear(int color) { canvas.drawRGB((color & 0xff0000) >> 16, (color & 0xff00) >> 8, (color & 0xff)); } @Override public void DrawPixel(int x, int y, int color) { paint.setColor(color); canvas.drawPoint(x, y, paint); } @Override public void DrawLine(int x, int y, int x2, int y2, int color) { paint.setColor(color); canvas.drawLine(x, y, x2, y2, paint); } @Override public void DrawRect(int x, int y, int width, int height, int color) { paint.setColor(color); paint.setStyle(Style.FILL); canvas.drawRect(x, y, x + width - 1, y + width - 1, paint); } @Override public void DrawImage(Image image, int x, int y, int srcX, int srcY, int srcWidth, int srcHeight) { srcRect.left = srcX; srcRect.top = srcY; srcRect.right = srcX + srcWidth - 1; srcRect.bottom = srcY + srcHeight - 1; dstRect.left = x; dstRect.top = y; dstRect.right = x + srcWidth - 1; dstRect.bottom = y + srcHeight - 1; canvas.drawBitmap(((MechanicImage)image).bitmap, srcRect, dstRect, null); } @Override public void DrawImage(Image image, int x, int y) { canvas.drawBitmap(((MechanicImage)image).bitmap, x, y, null); } @Override public int GetWidth() { return buffer.getWidth(); } @Override public int GetHeight() { return buffer.getHeight(); } }
Обратите внимание! Мы не создаем объекты Paint и Rect каждый раз при отрисовке, так как это преступление против сборщика мусора.
В конструкторе мы берем Bitmap — буфер, на котором будем все рисовать, его использует canvas.
По загрузке изображения мы считываем картинку из ассетов, а потом декодируем ее в Bitmap. Бросается исключение, если загружаемый файл не картинка или если его не существует, потом файл закрывается. Под конец мы берем формат картинки и возвращаем новый MechanicImage, передавая в конструктор Bitmap и ImageFormat. Также внимание заслуживает первый метод DrawImage, который рисует часть картинки. Это применяется, когда вместо отдельных изображений картинок в игре используется группа картинок, называемая атласом. Вот пример такого атласа
(изображение взято из веб-ресурса interesnoe.info)
Допустим, нам потребовалось отрисовать часть картинки с 32,32 по 48,48, в позиции 1,1; тогда мы делаем так
DrawImage(image, 1, 1, 32, 32, 16, 16);
Остальные методы легко понятны и интереса не представляют.
Настало время для интерфейсов Game и Screen!
Перед тем, как продолжать, нам нужно отрисовывать графику в отдельном потоке и не загружать пользовательский поток.
Встречайте класс SurfaceView, который предлагает в отдельном потоке рисовать графику. Создайте класс Runner
package com.mechanic.game; import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Rect; import android.view.SurfaceHolder; import android.view.SurfaceView; public class Runner extends SurfaceView implements Runnable { MechanicGame game; Canvas canvas; Bitmap buffer; Thread thread = null; SurfaceHolder holder; volatile boolean running = false; public Runner(Object context, MechanicGame game, Bitmap buffer) { super(game); this.game = game; this.buffer = buffer; this.holder = getHolder(); } public void Resume() { running = true; thread = new Thread(this); thread.start(); } public void run() { Rect dstRect = new Rect(); long startTime = System.nanoTime(); while(running) { if(!holder.getSurface().isValid()) continue; float deltaTime = (System.nanoTime()-startTime) / 1000000000.0f; startTime = System.nanoTime(); game.GetCurrentScreen().Update(deltaTime); game.GetCurrentScreen().Present(deltaTime); canvas = holder.lockCanvas(); canvas.getClipBounds(dstRect); canvas.drawBitmap(buffer, null, dstRect, null); holder.unlockCanvasAndPost(canvas); } } public void Pause() { running = false; while(true) { try { thread.join(); break; } catch (InterruptedException e) { } } } }
Класс MechanicGame скоро будет, не волнуйтесь.
Для рисования графики не в пользовательском интерфейсе нам нужен объект SurfaceHolder. Его главные функции – lockCanvas и unlockCanvasAndPost. Первая функция блокирует Surface и возвращает Canvas, на котором можно что-нибудь рисовать (в нашем случае – буфер Bitmap, который выступает в роли холста).
В функции Resume мы запускаем новый поток с этим классом.
В функции run, пока приложение работает, берется прошедший промежуток с прошлого цикла (System.nanoTime возвращает наносекунды) и вызываются функции Update и Present текущего Screen’а приложения, после чего рисуется буфер.
Вот класс MechanicGame
package com.mechanic.game; import com.mechanic.audio.Audio; import com.mechanic.audio.MechanicAudio; import com.mechanic.fileio.FileIO; import com.mechanic.fileio.MechanicFileIO; import com.mechanic.graphics.Graphics; import com.mechanic.graphics.MechanicGraphics; import com.mechanic.input.Input; import com.mechanic.input.MechanicInput; import android.app.Activity; import android.content.Context; import android.content.res.Configuration; import android.graphics.Bitmap; import android.graphics.Bitmap.Config; import android.os.Bundle; import android.os.PowerManager; import android.os.PowerManager.WakeLock; import android.view.Window; import android.view.WindowManager; public abstract class MechanicGame extends Activity implements Game { Runner runner; Graphics graphics; Audio audio; Input input; FileIO fileIO; Screen screen; WakeLock wakeLock; static final int SCREEN_WIDTH = 80; static final int SCREEN_HEIGHT = 128; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); requestWindowFeature(Window.FEATURE_NO_TITLE); getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN); boolean IsLandscape = (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE); int frameBufferWidth = IsLandscape ? SCREEN_HEIGHT : SCREEN_WIDTH; int frameBufferHeight = IsLandscape ? SCREEN_WIDTH : SCREEN_HEIGHT; Bitmap frameBuffer = Bitmap.createBitmap(frameBufferWidth, frameBufferHeight, Config.RGB_565); float scaleX = (float) frameBufferWidth / getWindowManager().getDefaultDisplay().getWidth(); float scaleY = (float) frameBufferHeight / getWindowManager().getDefaultDisplay().getHeight(); runner = new Runner(null, this, frameBuffer); graphics = new MechanicGraphics(getAssets(), frameBuffer); fileIO = new MechanicFileIO(getAssets()); audio = new MechanicAudio(this); input = new MechanicInput(this, runner, scaleX, scaleY); screen = GetStartScreen(); setContentView(runner); PowerManager powerManager = (PowerManager) getSystemService(Context.POWER_SERVICE); wakeLock = powerManager.newWakeLock(PowerManager.FULL_WAKE_LOCK, "Game"); } @Override public Input GetInput() { return input; } @Override public FileIO GetFileIO() { return fileIO; } @Override public Graphics GetGraphics() { return graphics; } @Override public Audio GetAudio() { return audio; } @Override public void SetScreen(Screen screen) { if (screen == null) throw new IllegalArgumentException("Screen не может быть null"); this.screen.Pause(); this.screen.Dispose(); screen.Resume(); screen.Update(0); this.screen = screen; } @Override public Screen GetCurrentScreen() { return screen; } @Override public Screen GetStartScreen() { return null; } @Override public void onResume() { super.onResume(); wakeLock.acquire(); screen.Resume(); runner.Resume(); } @Override public void onPause() { super.onPause(); wakeLock.release(); runner.Pause(); screen.Pause(); if(isFinishing()) screen.Dispose(); } }
У этого класса есть объекты Runner, всех наших интерфейсов и классов и объект WakeLock (нужен для того, чтобы телефон не засыпал, когда запущена игра)
Также у него есть 2 константы – SCREEN_WIDTH и SCREEN_HEIGHT, которые очень важны!
У устройств множество разрешений, и почти невозможно и бессмысленно под каждое устройство подстраивать размеры картинок, вычислять местоположение и т.д. и т.п. Представьте, что у нас есть окошко размером 80×128 пикселей (из двух вышеназванных констант). Мы в этом окошке рисуем маленькие картинки. Но вдруг размер экрана устройства не подходит по размеру этому окошку. Что делать? Все очень просто – мы берем отношение ширины и длины нашего окошка к ширине и длине устройства и рисуем все картинки, учитывая это отношение.
В итоге приложение само растягивает картинки под экран устройства.
Этот класс включает в себя Activity и у него есть методы onCreate, onResume и onPause.
В onCreate сначала приложение переходит в полноэкранный режим (чтобы не было видно зарядки и времени вверху). Потом выясняется ориентация телефона – ландшафтная или портретная (которая уже прописана в .xml файле в начале статьи). Потом создается долгожданный буфер с размером с это вот окошко 80×128 пикселей, выясняется отношение этого окошка к размеру устройства, которое передается в конструктор MechanicInput, он, в свою очередь, передает отношение в MechanicTouch. И тут – бинго! Полученные точки касания на экран умножаются на это отношение, так что координаты нажатия не зависят от размеров устройства.
Дальше создаем наши интерфейсы, регистрируем Runner и WakeLock.
В методе SetScreen мы освобождаем текущий Screen и записываем другой Screen.
Остальные методы интереса не предоставляют.
Неужели это все?
Да, господа, фреймворк уже готов!
When it’s done.
А как теперь связать фреймворк с главным классом, допустим, с MyGame?
«Главный» класс выглядит примерно так
public class MyGame extends Activity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_my_game); } @Override public boolean onCreateOptionsMenu(Menu menu) { // Inflate the menu; this adds items to the action bar if it is present. getMenuInflater().inflate(R.menu.my_game, menu); return true; } }
Видоизменяем его до такого класса
package com.mechanic; import com.mechanic.game.MechanicGame; import com.mechanic.game.Screen; public class MyGame extends MechanicGame { @Override public Screen GetStartScreen() { return new GameScreen(this); } }
Java воспринимает этот класс как наследника от Activity, так как сам MechanicGame наследник от Activity. onCreate уже прописан, и единственное, что нам надо сделать – переопределить GetStartScreen(), так как в MechanicGame этот метод возвращает null, а это кидает ошибку.
Не забудьте реализовать класс GameScreen 🙂
package com.mechanic; import com.mechanic.game.Game; import com.mechanic.game.Screen; import com.mechanic.graphics.Graphics; import com.mechanic.graphics.Image; public class GameScreen extends Screen { Graphics g = game.GetGraphics(); Image wikitan; float x = 0.0f; public GameScreen(Game game) { super(game); wikitan = g.NewImage("wikipetan.png"); } @Override public void Update(float deltaTime) { if(game.GetInput().IsTouchDown(0)) x += 1.0f * deltaTime; } @Override public void Present(float deltaTime) { g.Clear(0); g.DrawImage(wikitan, (int)x, 0); } @Override public void Pause() { } @Override public void Resume() { } @Override public void Dispose() { wikitan.Dispose(); } }
Это простой пример реализации Screen, который загружает изображение Википе-тан и двигает его по клику на экран.
(Изображение взято из веб-ресурса ru.wikipedia.org)
Результат
Переменная x представлена как float, так как прибавление чисел от 0 до 1 ничего не дает, идет округление.
Википе-тан рисуется c увеличением, так как размер нашего холста 80×128 пикселей
Вопросы и ответы:
— У меня неправильно отрисовывается картинка – повернутой на 90 градусов!
— Это все потому что мы дали команду в xml файле работать только в ландшафтном режиме. Для переключения режима жмите на клавишу 7 в правой части клавиатуры
— Я честно изменяю x += 1.0f * deltaTime, но картинка не двигается с места или медленно двигается. Что делать?
— Эмулятор – очень медленная штука. Проверяйте работоспособность приложения на устройстве.
Have fun!
Исходники:
rghost.ru/49052713
Литература:
developer.alexanderklimov.ru/android/
habrahabr.ru/post/109944/
Книга Марио Цехнера «Программирование игр под Android»
ссылка на оригинал статьи http://habrahabr.ru/post/195830/
Добавить комментарий