AndEngine - свободно распространяемый 2D игровой движок, базирующийся на OpenGL.
Чтобы было интереснее и ближе к "жизни", мы используем в нашем приложении расширение Physics Box2D, которое позволит нам реализовать в игре гравитацию. Наша игра будет простой но вполне играбильной: мы будем складывать конструкции из деталей, которые будут появляться "из воздуха".
Итак, приступим.


Где взять движок?

Как ни странно, скачать его на официальном сайте или даже в репозитории на code.google.com не получится. Единственная ссылка на загрузку, которую я нашёл на вот этой wiki, ведёт на чей-то dropbox... Но, конечно же, всегда есть исходники, их можно склонировать и скомпилировать проект "у себя". Так мы и сделаем. Позаботьтесь предварительно, чтобы у вас в системе был установлен mercurial.

Выполняем hg clone https://code.google.com/p/andengine/ и получаем android library проект, который компилируется и даёт нам classes.jar, который мы подкладываем в каталог libs своего проекта и получаем в свои руки всю мощь AndEngine. Аналогично поступаем с расширением для 2D-физики: hg clone https://code.google.com/p/andenginephysicsbox2dextension/

Прежде чем углубиться в код, позаботьтесь ещё о том, чтобы найти файл libandenginephysicsbox2dextension.so (его можно взять в исходниках примеров использования библиотеки) и положить его в каталог libs/armeabi/ вашего проекта. Без него вы получите неприятную ошибку
Ljava/lang/UnsatisfiedLinkError; thrown during Lorg/anddev/andengine/extension/physics/box2d/PhysicsWorld
при попытке создать объект PhysicsWorld в вашем коде.

Сцена и камера

Все события в игре, как и в театре, происходят на сцене, а видим мы их при помощи камеры. Прежде чем что-то выполнить в игре, позаботимся об инициализации этих двух абстракций.
Создаём основной Activity нашего приложения:

 


{codecitation style="brush: java;"}

import net.multipi.catacombix.grafic.Textures;
import android.content.res.Resources;
import net.multipi.catacombix.grafic.ActiveSprite;
import org.anddev.andengine.engine.Engine;
import org.anddev.andengine.engine.camera.Camera;
import org.anddev.andengine.engine.options.EngineOptions;
import org.anddev.andengine.engine.options.EngineOptions.ScreenOrientation;
import org.anddev.andengine.engine.options.resolutionpolicy.RatioResolutionPolicy;
import org.anddev.andengine.entity.scene.Scene;
import org.anddev.andengine.entity.scene.Scene.IOnSceneTouchListener;
import org.anddev.andengine.entity.scene.background.ColorBackground;
import org.anddev.andengine.entity.shape.Shape;
import org.anddev.andengine.entity.sprite.Sprite;
import org.anddev.andengine.input.touch.TouchEvent;
import org.anddev.andengine.ui.activity.BaseGameActivity;
 
public class GameActivity extends BaseGameActivity implements IOnSceneTouchListener {
 
    private Camera mCamera;
    private Textures mTextures;
    private int CAMERA_WIDTH;
    private int CAMERA_HEIGHT;
 
    public Engine onLoadEngine() {
        Resources res = getResources();
        CAMERA_HEIGHT = res.getDisplayMetrics().heightPixels;
        CAMERA_WIDTH = res.getDisplayMetrics().widthPixels;
        this.mCamera = new Camera(0, 0, CAMERA_WIDTH, CAMERA_HEIGHT);
        RatioResolutionPolicy Resolution = new RatioResolutionPolicy(CAMERA_WIDTH, CAMERA_HEIGHT);
        EngineOptions engineOptions = new EngineOptions(true, ScreenOrientation.LANDSCAPE, Resolution, this.mCamera);
        return new Engine(engineOptions);
    }
 
    public void onLoadResources() {
        mTextures = new Textures(this, getEngine());
    }
 
    public Scene onLoadScene() {
        Scene scene = new Scene();
        scene.setOnSceneTouchListener(this);
        scene.setBackground(new ColorBackground(0.09804f, 0.6274f, 0.8784f));
        return scene;
    }
 
    public void onLoadComplete() {
    }
 
    private void addElement(float x, float y, Scene s) {
        // пока опустим это, вернёмся сюда позже...
    }
 
    public boolean onSceneTouchEvent(Scene scene, TouchEvent te) {
        if (te.isActionDown()) {
            addElement(te.getX(), te.getY(), scene);
            return true;
        }
        return false;
    }
}

{/codecitation}


Давайте рассмотрим этот код повнимательнее.
Как видно, мы наследуем BaseGameActivity - обёртку над Activity, которую предоставляет нам движок. Базовый класс заставляет нас реализовать четыре метода:

  • onLoadEngine - вызывается первым, должен содержать код инициализации движка. Что нужно сделать при инициализации? Как видно из примера - создать объект камеры, которому нужно в свою очередь задать границы видимой области. Наша камера будет видеть весь экран устройства, поэтому мы размеры видимой области не "хардкодим" а определяем тут же. И с поддержкой разных разрешений будет получше.
  • onLoadResources - вызывается тогда, когда движок подгружает в память ресурсы приложения. Именно тут дивные творения наших дизайнеров станут игровыми объектами. Эту логику спрячем в наш класс Textures, который рассмотрим позднее.
  • onLoadScene - вызывается следующим, требует реализовать код для инициализации сцены. Со "свежесозданной" сценой тут мы делаем две вещи: "навешиваем" на неё слушатель касаний и устанавливаем фон. Слушателем будет этот же Activity, ведь мы указали что он реализует интерфейс IOnSceneTouchListener (а значит были вынуждены реализовать метод onSceneTouchEvent). Фоном делаем обычный цвет, заданный как RGB (в долях единицы: 1, 1, 1 = белый).
  • onLoadComplete - сцена готова, декорации загружены, поднимается занавес! Ничего не делаем, у нас всё готово.
Метод onSceneTouchEvent мы реализовали в нашем классе для того, чтобы сцена "чувствовала" прикосновения.

"Активные" объекты

Тут остановимся подробнее. Все объекты, добавленные на сцену могут получать оповещения от неё в случае если игрок коснулся сцены там где они находятся. Для этого при создании объекта мы должны переопределить у него метод onAreaTouched, а после создания зарегистрировать этот объект, передав его в метод registerTouchArea нашей сцены. Всю эту логику мы упакуем в наш класс ActiveSprite, который нам очень пригодится дальше:
{codecitation style="brush: java;"}
import org.anddev.andengine.entity.scene.Scene;
import org.anddev.andengine.entity.shape.Shape;
import org.anddev.andengine.entity.sprite.Sprite;
import org.anddev.andengine.input.touch.TouchEvent;
import org.anddev.andengine.opengl.texture.region.TextureRegion;
 
public class ActiveSprite extends Sprite {
 
    private TouchListener tl;
    private boolean clicked = false;
 
    public ActiveSprite(float pX, float pY, TextureRegion pTextureRegion, TouchListener tl, Scene s) {
        super(pX, pY, pTextureRegion);
        this.tl = tl;
        s.registerTouchArea(this);
    }
 
    @Override
    public boolean onAreaTouched(TouchEvent pSceneTouchEvent, float pTouchAreaLocalX, float pTouchAreaLocalY) {
 
        if (pSceneTouchEvent.isActionDown()) {
            tl.onTouchDown(this, pSceneTouchEvent, pTouchAreaLocalX, pTouchAreaLocalY);
            clicked = true;
            return true;
        }
        if (pSceneTouchEvent.isActionUp()) {
            if (clicked) {
                clicked = false;
                tl.onClick(this, pSceneTouchEvent, pTouchAreaLocalX, pTouchAreaLocalY);
            }
            tl.onTouchUp(this, pSceneTouchEvent, pTouchAreaLocalX, pTouchAreaLocalY);
            return true;
        }
        if (pSceneTouchEvent.isActionMove()) {
            clicked = false;
            tl.onTouchMove(this, pSceneTouchEvent, pTouchAreaLocalX, pTouchAreaLocalY);
            return true;
        }
        return false;
    }
 
    public interface TouchListener {
 
        public abstract void onTouchDown(Shape shape, TouchEvent te, float f, float f1);
 
        public abstract void onTouchUp(Shape shape, TouchEvent te, float f, float f1);
 
        public abstract void onTouchMove(Shape shape, TouchEvent te, float x, float y);
 
        public abstract void onClick(Shape shape, TouchEvent te, float x, float y);
    }
}
{/codecitation}

Тут всё просто: класс наследует Sprite, который в свою очередь расширяет Shape - фигуру - добавляя в неё фон и кое-что ещё. Наша реализация принимает в конструкторе экземпляр "слушателя", интерфейс которого описан тут же и "дёргает" его методы в зависимости от вида прикосновения в переопределённом методе onAreaTouched.
Вариантов прикосновения немного больше, но нам интересны ActionDown (опускание пальца), ActionUp (поднятие) и ActionMove - передвижение. Кликом в нашем случае будем считать ActionUp, который произошёл сразу после ActionDown. Остальные методы "слушателя" вызываются по соответствующим событиям.
Появление наших "активных" спрайтов на сцене мы рассмотрим чуть позже, а пока выясним, откуда у них появятся текстуры.

Загрузка текстур

Наш загрузчик текстур выглядит так:

{codecitation style="brush: java;"}

import org.anddev.andengine.engine.Engine;
import org.anddev.andengine.opengl.texture.TextureOptions;
import org.anddev.andengine.opengl.texture.atlas.bitmap.BitmapTextureAtlas;
import org.anddev.andengine.opengl.texture.atlas.bitmap.BitmapTextureAtlasTextureRegionFactory;
import org.anddev.andengine.opengl.texture.region.TextureRegion;
import org.anddev.andengine.ui.activity.BaseGameActivity;
 
public class Textures {
 
    private TextureRegion element;
 
    public Textures(final BaseGameActivity activity, final Engine engine) {
        BitmapTextureAtlasTextureRegionFactory.setAssetBasePath("gfx/");
        BitmapTextureAtlas mTexture = new BitmapTextureAtlas(512, 1024, TextureOptions.NEAREST_PREMULTIPLYALPHA);
        this.element = BitmapTextureAtlasTextureRegionFactory.createFromAsset(mTexture, activity, "element.png", 0, 0);
        engine.getTextureManager().loadTexture(mTexture);
    }
 
    public TextureRegion getElement() {
        return element;
    }
}

{/codecitation}


Тут следует понять смысл ещё одной абстракции: атласа текстур. Это что-то вроде виртуального холста, куда мы "наклеиваем" наши текстуры при инициализации и "вырезаем"  перед использованием. В нашем случае мы создаём BitmapTextureAtlas с размерами кратными двойке (таково требование OpenGL) и заведомо большими, чем размер наших текстур. Потом нам остаётся загрузить на наш атлас (начиная с позиции 0;0) файл element.png из каталога asset/gfx/ нашего проекта. Используя несколько текстур мы должны "выкладывать" их на атлас так, чтобы они не пересекались.

Объект выходит на сцену

Наконец пришло время вывести на сцену нашего "героя": реализовать метод addElement главного Activity. Как мы описали в первом листинге, вызывается этот метод по событию onSceneTouchEvent, т.е. при прикосновении к экрану.

{codecitation style="brush: java;"}

    private void addElement(float x, float y, Scene s) {
        Sprite sprite = new ActiveSprite(x, y, mTextures.getElement(), new ActiveSprite.TouchListener() {
 
            public void onTouchDown(Shape shape, TouchEvent te, float x, float y) {
                shape.setScale(1.5f);
            }
 
            public void onTouchUp(Shape shape, TouchEvent te, float x, float y) {
                shape.setScale(1f);
            }
 
            public void onTouchMove(Shape shape, TouchEvent te, float x, float y) {
                shape.setPosition(te.getX() - shape.getWidth() / 2, te.getY() - shape.getHeight() / 2);
            }
 
            public void onClick(Shape shape, TouchEvent te, float x, float y) {
                shape.setRotation(90);
            }
        }, s);
        s.attachChild(sprite);
    }
{/codecitation}

Мы создаём ActiveSprite, передавая в его конструктор координаты в которых "возникает" объект, текстуру и "слушатель", методы которого реализуем тут же. При нажатию на объект увеличиваем его размеры в полтора раза, при "отпускании" - возвращаем исходный размер. При перетаскавании - меняем положение на экране, по клику - поворачиваем на 90 градусов.

Добавляем физику

Теперь мы можем добавить объект на экран, перетащить и повернуть его. Это уже интересно, но хочется больше "реализма". Пора добавить в наш проект использование физического движка.
Две новые абстракции, которые мы тут используем: PhysicsWorld и Body. Первая реализует объект "физического мира", который служит контейнером для "жизни" второй. Body - интересный объект, который позволяет применять к себе силы, возвращать данные о ускорении, направлении движения и т.п. Для того, чтобы наш ActiveSprite стал подчиняться законам "физического мира", его нужно связать со специально созданным для него "физическим телом". Это мы реализуем, добавив в конец метода addElement две строчки кода:

{codecitation style="brush: java;"}

Body body = PhysicsFactory.createBoxBody(mPhysicsWorld, sprite, BodyType.DynamicBody, FIXTURE_DEF);
mPhysicsWorld.registerPhysicsConnector(new PhysicsConnector(sprite, body, true, true));
{/codecitation}


Тут переменная mPhysicsWorld - объект нашего "физического мира". Она глобавльная в нашем Activity, а инициализацию её выполняем в том же методе, где устанавливаем остальные параметры нашего мира, в частности создаём его границы:

{codecitation style="brush: java;"}

    private void createPhysicBox(Scene mScene) {
        mPhysicsWorld = new PhysicsWorld(new Vector2(0, SensorManager.GRAVITY_EARTH), false);
 
        final Shape ground = new Rectangle(0, CAMERA_HEIGHT - 2, CAMERA_WIDTH, 2);
        final Shape roof = new Rectangle(0, 0, CAMERA_WIDTH, 2);
        final Shape left = new Rectangle(0, 0, 2, CAMERA_HEIGHT);
        final Shape right = new Rectangle(CAMERA_WIDTH - 2, 0, 2, CAMERA_HEIGHT);
 
        final FixtureDef wallFixtureDef = PhysicsFactory.createFixtureDef(0, 0.5f, 0.5f);
        PhysicsFactory.createBoxBody(mPhysicsWorld, ground, BodyType.StaticBody, wallFixtureDef);
        PhysicsFactory.createBoxBody(mPhysicsWorld, roof, BodyType.StaticBody, wallFixtureDef);
        PhysicsFactory.createBoxBody(mPhysicsWorld, left, BodyType.StaticBody, wallFixtureDef);
        PhysicsFactory.createBoxBody(mPhysicsWorld, right, BodyType.StaticBody, wallFixtureDef);
 
        mScene.attachChild(ground);
        mScene.attachChild(roof);
        mScene.attachChild(left);
        mScene.attachChild(right);
        mScene.registerUpdateHandler(mPhysicsWorld);
    }

{/codecitation}


Также нам понадобится ещё одна константа:

{codecitation style="brush: java;"}

private static final FixtureDef FIXTURE_DEF = PhysicsFactory.createFixtureDef(1, 0.5f, 0.5f);

{/codecitation}


Её устанавливаем статически. Собранный с такими изменениями проект реализует уже вполне приличную игровую логику: предметы "возникают из воздуха" в том месте, где мы касаемся экрана и красиво падают под действием "гравитации". Кстати о гравитации: её направление мы задаём при помощи сенсора, переопределив в нашем Activity метод:

{codecitation style="brush: java;"}

    @Override
    public void onAccelerometerChanged(final AccelerometerData pAccelerometerData) {
        final Vector2 gravity = Vector2Pool.obtain(pAccelerometerData.getX(), pAccelerometerData.getY());
        mPhysicsWorld.setGravity(gravity);
        Vector2Pool.recycle(gravity);
    }


Сенсор надо не забыть включить при старте игры и освободить при паузе или выключении:

{codecitation style="brush: java;"}

    @Override
    public void onResumeGame() {
        super.onResumeGame();
        this.enableAccelerometerSensor(this);
    }
 
    @Override
    public void onPauseGame() {
        super.onPauseGame();
        this.disableAccelerometerSensor();
    }

{/codecitation}


Теперь при повороте устройства мы заставим "пол" стать "стеной", что приведёт к разрушению нашей конструкции. Приложение почти готово. Остаётся ещё одна неприятная мелочь: с тех пор как элементы конструкции стали "физическими телами", мы утратили возможность перемещать их "вручную". Чтобы вернуть себе власть над предметами, нужно временно "изъять" их из физического мира, а по окончанию трансформации вернуть обратно. Добиваемся этого, ещё немного исправив метод addElement. "Вынимать объект из мира" мы будем при прикосновении к нему, а "возвращать в мир" при отпускании:

{codecitation style="brush: java;"}

private boolean unlink = false;
 
public void onTouchDown(Shape shape, TouchEvent te, float x, float y) {
    if (!unlink) {
        PhysicsConnector mPhysicsConnector = mPhysicsWorld.getPhysicsConnectorManager().findPhysicsConnectorByShape(shape);
        if (mPhysicsConnector != null) {
            mPhysicsWorld.unregisterPhysicsConnector(mPhysicsConnector);
            mPhysicsWorld.destroyBody(mPhysicsConnector.getBody());
            unlink = true;
        }
    }
    shape.setScale(1.5f);
}
 
public void onTouchUp(Shape shape, TouchEvent te, float x, float y) {
    if (unlink && mPhysicsWorld!=null) {
        Body body = PhysicsFactory.createBoxBody(mPhysicsWorld, shape, BodyType.DynamicBody, FIXTURE_DEF);
        mPhysicsWorld.registerPhysicsConnector(new PhysicsConnector(shape, body, true, true));
        unlink = false;
    }
    shape.setScale(1f);
}

{/codecitation}


Это изменившаяся часть метода.
В принципе, мы решили все задачи, которые наметили. В нашей игре, безусловно остались "шероховатости", но главного мы добились: мы манипулируем объектами в виртуальном мире, в котором действуют законы его внутренней физики.