Pull to refresh

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

Reading time 26 min
Views 25K
Добрый день, жители Хабра!
Сегодня мы будем писать фреймворк с названием 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, которые очень важны!
У устройств множество разрешений, и почти невозможно и бессмысленно под каждое устройство подстраивать размеры картинок, вычислять местоположение и т.д. и т.п. Представьте, что у нас есть окошко размером 80x128 пикселей (из двух вышеназванных констант). Мы в этом окошке рисуем маленькие картинки. Но вдруг размер экрана устройства не подходит по размеру этому окошку. Что делать? Все очень просто – мы берем отношение ширины и длины нашего окошка к ширине и длине устройства и рисуем все картинки, учитывая это отношение.
В итоге приложение само растягивает картинки под экран устройства.

Этот класс включает в себя Activity и у него есть методы onCreate, onResume и onPause.
В onCreate сначала приложение переходит в полноэкранный режим (чтобы не было видно зарядки и времени вверху). Потом выясняется ориентация телефона – ландшафтная или портретная (которая уже прописана в .xml файле в начале статьи). Потом создается долгожданный буфер с размером с это вот окошко 80x128 пикселей, выясняется отношение этого окошка к размеру устройства, которое передается в конструктор 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 увеличением, так как размер нашего холста 80x128 пикселей

Вопросы и ответы:


— У меня неправильно отрисовывается картинка – повернутой на 90 градусов!
— Это все потому что мы дали команду в xml файле работать только в ландшафтном режиме. Для переключения режима жмите на клавишу 7 в правой части клавиатуры
— Я честно изменяю x += 1.0f * deltaTime, но картинка не двигается с места или медленно двигается. Что делать?
— Эмулятор – очень медленная штука. Проверяйте работоспособность приложения на устройстве.

Have fun!

Исходники:
rghost.ru/49052713
github.com/Izaron/MechanicFramework
Литература:
developer.alexanderklimov.ru/android
habrahabr.ru/post/109944
Книга Марио Цехнера «Программирование игр под Android»

Tags:
Hubs:
+36
Comments 20
Comments Comments 20

Articles