понедельник, 19 декабря 2011 г.

Пишем игру для Android с помощью AndEngine и Physics Box2D Extension

В жизни каждого программиста наступает время, когда ему надоедает писать унылые формы и обработчики и душа просит настоящего творчества.. Например, написать игру.
Давайте посмотрим, как это делается для нашей любимой платформы Android.
Всякая игра базируется на наборе логики, которая реализует поведение игровых объектов на экране, т.н. "графическом движке". В комплексе с этой логикй работает игровая физика, "звуковой" движок, и масса другого, очень непростого кода. Если у нас нет лишних полгода жизни или полмиллиона баксов, лучше не пытаться реализовать эту логику самому. Благо под Android создано уже несколько достаточно мощных "двигателей". Один из них мы сегодня и применим. Это 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 нашего приложения:

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;
    }
}

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

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

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

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

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

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

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;
    }
}

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

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

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

    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);
    }
Мы создаём ActiveSprite, передавая в его конструктор координаты в которых "возникает" объект, текстуру и "слушатель", методы которого реализуем тут же. При нажатию на объект увеличиваем его размеры в полтора раза, при "отпускании" - возвращаем исходный размер. При перетаскавании - меняем положение на экране, по клику - поворачиваем на 90 градусов.

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

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

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

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

    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);
    }

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

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

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

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

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

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

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

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);
}

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

8 комментариев:

  1. на code.google.com можно скачать anengine.jar вот по этой ссылочке http://code.google.com/p/andengineexamples/source/browse/lib/andengine.jar?r=af89d8cc71ca5d8ff399ee3d11035a24fbc938f9
    внизу справа есть ссылочка View raw file - вот это и есть то, что надо или тут http://code.google.com/p/andengineexamples/source/browse/
    меняем Branch на Android 1.5 и заходим в папку lib, там=то всё и лежит, вместе с расширениями.

    ОтветитьУдалить
  2. Отличная статья, одна из немногих, в которой описана взаимосвязь AndEnginie + Box2D

    ОтветитьУдалить
  3. то что надо для того чтобы рвать мотор ))

    ОтветитьУдалить
  4. Интересная статья, а можно где-то скачать исходники?

    ОтветитьУдалить
  5. Этот комментарий был удален автором.

    ОтветитьУдалить
  6. Автору респект за статью!
    Но вот вопрос: можно чуть подробнее о Physics Box2D, а именно как мы его цепляем к проекту? Вчера пол ночи пытался разобраться, но у меня ругалось на всё, где есть слово Physics...

    Опять же, исходники бы очень помогли!

    ОтветитьУдалить