Пишем фреймворк для разработки игр — Mechanic Framework

Добрый день, жители Хабра!
Сегодня мы будем писать фреймворк с названием Mechanic Framework для удобной разработки игр под андроид.

image

Что нам потребуется:

  • Установленные Eclipse и Android SDK
  • Приличное знание Java либо другого С-подобного языка. Лучший пример – C#
  • Терпение


Для начала создаем проект.
File – New – Other – Android Application Project

image
Появляется окошко New Android Application. Вводим любое имя (например, Mechanic), называем package своим именем, выбираем минимально возможную версию андроид для приложения и целевую версию, нажимаем Next.

image
Нажимаем Next.

image
Выбираем иконку (если вам не нравится иконка андроида, жмите Clipart – Choose и выбираем что-нибудь, либо ставим свою иконку).

image
Жмем Next.

image
Выбираем название для Activity, например, MyGame, жмем Finish.

Откроется .xml окно визуального редактирования, закрываем его.
Открываем AndroidManifest.xml и настраиваем его под свои нужды

image

Для того, чтобы устанавливать игру на карту памяти, когда это возможно, и не загрязнять внутреннюю память устройства, в поле 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>  

Закрываем манифест

Теперь необходимо создать каркас фреймворка – интерфейсы, управляющие вводом, отрисовкой графики и прочим, а позже все интерфейсы реализовать.
image

Ввод

Создаем новый 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, который рисует часть картинки. Это применяется, когда вместо отдельных изображений картинок в игре используется группа картинок, называемая атласом. Вот пример такого атласа
image
(изображение взято из веб-ресурса 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, который загружает изображение Википе-тан и двигает его по клику на экран.
image
(Изображение взято из веб-ресурса ru.wikipedia.org)

Результат
image

Переменная 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/

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *